Skip to content

Commit d34cf0e

Browse files
authored
Merge pull request #107 from toddaheath/feature/dimension-input-component
Extract DimensionInput component from DesignPanel
2 parents 059bf73 + 1edf78e commit d34cf0e

File tree

3 files changed

+184
-57
lines changed

3 files changed

+184
-57
lines changed

src/shed-builder-ui/src/components/DesignPanel.tsx

Lines changed: 24 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '@mui/material';
1616
import DeleteIcon from '@mui/icons-material/Delete';
1717
import AddIcon from '@mui/icons-material/Add';
18+
import DimensionInput from './DimensionInput';
1819
import type { Design, UpdateDesignRequest, SaveStatus, RoofType, Opening, OpeningType, WallSide } from '../types';
1920

2021
interface Props {
@@ -76,65 +77,31 @@ export default memo(function DesignPanel({ design, onChange, saveStatus }: Props
7677
sx={{ mb: 2 }}
7778
/>
7879

79-
<Typography variant="subtitle2" gutterBottom id="width-label">Width</Typography>
80-
<Stack direction="row" spacing={1} mb={2} role="group" aria-labelledby="width-label">
81-
<TextField
82-
label="Feet"
83-
type="number"
84-
size="small"
85-
value={design.widthFeet}
86-
onChange={(e) => onChange({ widthFeet: Number(e.target.value) })}
87-
inputProps={{ min: 4, max: 60, 'aria-label': 'Width feet' }}
88-
/>
89-
<TextField
90-
label="Inches"
91-
type="number"
92-
size="small"
93-
value={design.widthInches}
94-
onChange={(e) => onChange({ widthInches: Number(e.target.value) })}
95-
inputProps={{ min: 0, max: 11, 'aria-label': 'Width inches' }}
96-
/>
97-
</Stack>
80+
<DimensionInput
81+
label="Width"
82+
feet={design.widthFeet}
83+
inches={design.widthInches}
84+
onFeetChange={(v) => onChange({ widthFeet: v })}
85+
onInchesChange={(v) => onChange({ widthInches: v })}
86+
/>
9887

99-
<Typography variant="subtitle2" gutterBottom id="depth-label">Depth</Typography>
100-
<Stack direction="row" spacing={1} mb={2} role="group" aria-labelledby="depth-label">
101-
<TextField
102-
label="Feet"
103-
type="number"
104-
size="small"
105-
value={design.depthFeet}
106-
onChange={(e) => onChange({ depthFeet: Number(e.target.value) })}
107-
inputProps={{ min: 4, max: 60, 'aria-label': 'Depth feet' }}
108-
/>
109-
<TextField
110-
label="Inches"
111-
type="number"
112-
size="small"
113-
value={design.depthInches}
114-
onChange={(e) => onChange({ depthInches: Number(e.target.value) })}
115-
inputProps={{ min: 0, max: 11, 'aria-label': 'Depth inches' }}
116-
/>
117-
</Stack>
88+
<DimensionInput
89+
label="Depth"
90+
feet={design.depthFeet}
91+
inches={design.depthInches}
92+
onFeetChange={(v) => onChange({ depthFeet: v })}
93+
onInchesChange={(v) => onChange({ depthInches: v })}
94+
/>
11895

119-
<Typography variant="subtitle2" gutterBottom id="height-label">Wall Height</Typography>
120-
<Stack direction="row" spacing={1} mb={2} role="group" aria-labelledby="height-label">
121-
<TextField
122-
label="Feet"
123-
type="number"
124-
size="small"
125-
value={design.heightFeet}
126-
onChange={(e) => onChange({ heightFeet: Number(e.target.value) })}
127-
inputProps={{ min: 6, max: 20, 'aria-label': 'Wall height feet' }}
128-
/>
129-
<TextField
130-
label="Inches"
131-
type="number"
132-
size="small"
133-
value={design.heightInches}
134-
onChange={(e) => onChange({ heightInches: Number(e.target.value) })}
135-
inputProps={{ min: 0, max: 11, 'aria-label': 'Wall height inches' }}
136-
/>
137-
</Stack>
96+
<DimensionInput
97+
label="Wall Height"
98+
feet={design.heightFeet}
99+
inches={design.heightInches}
100+
onFeetChange={(v) => onChange({ heightFeet: v })}
101+
onInchesChange={(v) => onChange({ heightInches: v })}
102+
minFeet={6}
103+
maxFeet={20}
104+
/>
138105

139106
<TextField
140107
label="Roof Pitch (rise per 12 run)"
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { memo } from 'react';
2+
import { TextField, Typography, Stack } from '@mui/material';
3+
4+
interface Props {
5+
label: string;
6+
feet: number;
7+
inches: number;
8+
onFeetChange: (value: number) => void;
9+
onInchesChange: (value: number) => void;
10+
minFeet?: number;
11+
maxFeet?: number;
12+
}
13+
14+
export default memo(function DimensionInput({
15+
label,
16+
feet,
17+
inches,
18+
onFeetChange,
19+
onInchesChange,
20+
minFeet = 4,
21+
maxFeet = 60,
22+
}: Props) {
23+
const slug = label.toLowerCase().replace(/\s+/g, '-');
24+
const ariaPrefix = label.charAt(0).toUpperCase() + label.slice(1).toLowerCase();
25+
26+
return (
27+
<>
28+
<Typography variant="subtitle2" gutterBottom id={`${slug}-label`}>{label}</Typography>
29+
<Stack direction="row" spacing={1} mb={2} role="group" aria-labelledby={`${slug}-label`}>
30+
<TextField
31+
label="Feet"
32+
type="number"
33+
size="small"
34+
value={feet}
35+
onChange={(e) => onFeetChange(Number(e.target.value))}
36+
inputProps={{ min: minFeet, max: maxFeet, 'aria-label': `${ariaPrefix} feet` }}
37+
/>
38+
<TextField
39+
label="Inches"
40+
type="number"
41+
size="small"
42+
value={inches}
43+
onChange={(e) => onInchesChange(Number(e.target.value))}
44+
inputProps={{ min: 0, max: 11, 'aria-label': `${ariaPrefix} inches` }}
45+
/>
46+
</Stack>
47+
</>
48+
);
49+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import { describe, it, expect, vi } from 'vitest';
4+
import DimensionInput from '../DimensionInput';
5+
6+
describe('DimensionInput', () => {
7+
it('renders label and both fields', () => {
8+
render(
9+
<DimensionInput
10+
label="Width"
11+
feet={8}
12+
inches={6}
13+
onFeetChange={vi.fn()}
14+
onInchesChange={vi.fn()}
15+
/>
16+
);
17+
expect(screen.getByText('Width')).toBeInTheDocument();
18+
expect(screen.getByRole('spinbutton', { name: 'Width feet' })).toHaveValue(8);
19+
expect(screen.getByRole('spinbutton', { name: 'Width inches' })).toHaveValue(6);
20+
});
21+
22+
it('calls onFeetChange when feet value changes', async () => {
23+
const onFeetChange = vi.fn();
24+
render(
25+
<DimensionInput
26+
label="Depth"
27+
feet={10}
28+
inches={0}
29+
onFeetChange={onFeetChange}
30+
onInchesChange={vi.fn()}
31+
/>
32+
);
33+
await userEvent.type(screen.getByRole('spinbutton', { name: 'Depth feet' }), '2');
34+
expect(onFeetChange).toHaveBeenCalled();
35+
});
36+
37+
it('calls onInchesChange when inches value changes', async () => {
38+
const onInchesChange = vi.fn();
39+
render(
40+
<DimensionInput
41+
label="Height"
42+
feet={8}
43+
inches={0}
44+
onFeetChange={vi.fn()}
45+
onInchesChange={onInchesChange}
46+
/>
47+
);
48+
await userEvent.type(screen.getByRole('spinbutton', { name: 'Height inches' }), '6');
49+
expect(onInchesChange).toHaveBeenCalled();
50+
});
51+
52+
it('uses custom min/max for feet', () => {
53+
render(
54+
<DimensionInput
55+
label="Wall Height"
56+
feet={8}
57+
inches={0}
58+
onFeetChange={vi.fn()}
59+
onInchesChange={vi.fn()}
60+
minFeet={6}
61+
maxFeet={20}
62+
/>
63+
);
64+
const feetInput = screen.getByRole('spinbutton', { name: 'Wall height feet' });
65+
expect(feetInput).toHaveAttribute('min', '6');
66+
expect(feetInput).toHaveAttribute('max', '20');
67+
});
68+
69+
it('uses default min/max when not specified', () => {
70+
render(
71+
<DimensionInput
72+
label="Width"
73+
feet={8}
74+
inches={0}
75+
onFeetChange={vi.fn()}
76+
onInchesChange={vi.fn()}
77+
/>
78+
);
79+
const feetInput = screen.getByRole('spinbutton', { name: 'Width feet' });
80+
expect(feetInput).toHaveAttribute('min', '4');
81+
expect(feetInput).toHaveAttribute('max', '60');
82+
});
83+
84+
it('has proper accessibility group structure', () => {
85+
render(
86+
<DimensionInput
87+
label="Width"
88+
feet={8}
89+
inches={0}
90+
onFeetChange={vi.fn()}
91+
onInchesChange={vi.fn()}
92+
/>
93+
);
94+
const group = screen.getByRole('group');
95+
expect(group).toHaveAttribute('aria-labelledby', 'width-label');
96+
});
97+
98+
it('lowercases multi-word labels for aria-labels', () => {
99+
render(
100+
<DimensionInput
101+
label="Wall Height"
102+
feet={8}
103+
inches={0}
104+
onFeetChange={vi.fn()}
105+
onInchesChange={vi.fn()}
106+
/>
107+
);
108+
expect(screen.getByRole('spinbutton', { name: 'Wall height feet' })).toBeInTheDocument();
109+
expect(screen.getByRole('spinbutton', { name: 'Wall height inches' })).toBeInTheDocument();
110+
});
111+
});

0 commit comments

Comments
 (0)