Skip to content

Commit f4c1b5e

Browse files
kitfunsoclaude
andcommitted
a11y: improve accessibility of UI components
- Slider: Add proper aria-labelledby, aria-valuemin/max/now/text - ButtonGroup: Add role="radiogroup", role="radio", aria-checked - ButtonGroup: Add keyboard navigation (arrow keys) - Create Toggle component with role="switch", aria-checked - Update PetCost, PaintCalculator, MovingCost to use Toggle Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9217a54 commit f4c1b5e

7 files changed

Lines changed: 240 additions & 143 deletions

File tree

src/components/calculators/MovingCost/MovingCost.tsx

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
MetricCard,
2929
Alert,
3030
ButtonGroup,
31+
Toggle,
3132
} from '../../ui';
3233
import ShareResults from '../../ui/ShareResults';
3334

@@ -163,44 +164,24 @@ export default function MovingCost() {
163164
{ key: 'hasStairs', label: 'Stairs at Origin/Destination' },
164165
{ key: 'requiresElevator', label: 'Elevator Building' },
165166
].map(({ key, label }) => (
166-
<div key={key} className="flex items-center gap-3">
167-
<button
168-
onClick={() =>
169-
updateInput(
170-
key as keyof MovingCostInputs,
171-
!inputs[key as keyof MovingCostInputs]
172-
)
173-
}
174-
className={`w-10 h-5 rounded-full transition-all ${
175-
inputs[key as keyof MovingCostInputs] ? 'bg-emerald-500' : 'bg-white/20'
176-
}`}
177-
>
178-
<div
179-
className={`w-4 h-4 rounded-full bg-white transition-transform ${
180-
inputs[key as keyof MovingCostInputs] ? 'translate-x-5' : 'translate-x-0.5'
181-
}`}
182-
/>
183-
</button>
184-
<span className="text-[var(--color-cream)] text-sm">{label}</span>
185-
</div>
167+
<Toggle
168+
key={key}
169+
checked={inputs[key as keyof MovingCostInputs] as boolean}
170+
onChange={(checked) => updateInput(key as keyof MovingCostInputs, checked)}
171+
label={label}
172+
size="sm"
173+
/>
186174
))}
187175

188176
{/* Storage */}
189177
<div className="pt-2 border-t border-white/10">
190-
<div className="flex items-center gap-3 mb-3">
191-
<button
192-
onClick={() => updateInput('needsStorage', !inputs.needsStorage)}
193-
className={`w-10 h-5 rounded-full transition-all ${
194-
inputs.needsStorage ? 'bg-emerald-500' : 'bg-white/20'
195-
}`}
196-
>
197-
<div
198-
className={`w-4 h-4 rounded-full bg-white transition-transform ${
199-
inputs.needsStorage ? 'translate-x-5' : 'translate-x-0.5'
200-
}`}
201-
/>
202-
</button>
203-
<span className="text-[var(--color-cream)] text-sm">Need Storage</span>
178+
<div className="mb-3">
179+
<Toggle
180+
checked={inputs.needsStorage}
181+
onChange={(checked) => updateInput('needsStorage', checked)}
182+
label="Need Storage"
183+
size="sm"
184+
/>
204185
</div>
205186
{inputs.needsStorage && (
206187
<Slider

src/components/calculators/PaintCalculator/PaintCalculator.tsx

Lines changed: 19 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
ResultCard,
3131
MetricCard,
3232
Alert,
33+
Toggle,
3334
} from '../../ui';
3435
import ShareResults from '../../ui/ShareResults';
3536

@@ -249,51 +250,24 @@ export default function PaintCalculator() {
249250

250251
{/* Options */}
251252
<div className="space-y-3">
252-
<div className="flex items-center gap-3">
253-
<button
254-
onClick={() => updateInput('needsPrimer', !inputs.needsPrimer)}
255-
className={`w-10 h-5 rounded-full transition-all ${
256-
inputs.needsPrimer ? 'bg-amber-500' : 'bg-white/20'
257-
}`}
258-
>
259-
<div
260-
className={`w-4 h-4 rounded-full bg-white transition-transform ${
261-
inputs.needsPrimer ? 'translate-x-5' : 'translate-x-0.5'
262-
}`}
263-
/>
264-
</button>
265-
<span className="text-[var(--color-cream)] text-sm">Include Primer</span>
266-
</div>
267-
<div className="flex items-center gap-3">
268-
<button
269-
onClick={() => updateInput('includeCeiling', !inputs.includeCeiling)}
270-
className={`w-10 h-5 rounded-full transition-all ${
271-
inputs.includeCeiling ? 'bg-amber-500' : 'bg-white/20'
272-
}`}
273-
>
274-
<div
275-
className={`w-4 h-4 rounded-full bg-white transition-transform ${
276-
inputs.includeCeiling ? 'translate-x-5' : 'translate-x-0.5'
277-
}`}
278-
/>
279-
</button>
280-
<span className="text-[var(--color-cream)] text-sm">Paint Ceiling</span>
281-
</div>
282-
<div className="flex items-center gap-3">
283-
<button
284-
onClick={() => updateInput('includeTrim', !inputs.includeTrim)}
285-
className={`w-10 h-5 rounded-full transition-all ${
286-
inputs.includeTrim ? 'bg-amber-500' : 'bg-white/20'
287-
}`}
288-
>
289-
<div
290-
className={`w-4 h-4 rounded-full bg-white transition-transform ${
291-
inputs.includeTrim ? 'translate-x-5' : 'translate-x-0.5'
292-
}`}
293-
/>
294-
</button>
295-
<span className="text-[var(--color-cream)] text-sm">Include Trim Paint</span>
296-
</div>
253+
<Toggle
254+
checked={inputs.needsPrimer}
255+
onChange={(checked) => updateInput('needsPrimer', checked)}
256+
label="Include Primer"
257+
size="sm"
258+
/>
259+
<Toggle
260+
checked={inputs.includeCeiling}
261+
onChange={(checked) => updateInput('includeCeiling', checked)}
262+
label="Paint Ceiling"
263+
size="sm"
264+
/>
265+
<Toggle
266+
checked={inputs.includeTrim}
267+
onChange={(checked) => updateInput('includeTrim', checked)}
268+
label="Include Trim Paint"
269+
size="sm"
270+
/>
297271
</div>
298272
</div>
299273

src/components/calculators/PetCost/PetCost.tsx

Lines changed: 21 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
MetricCard,
2929
Alert,
3030
ButtonGroup,
31+
Toggle,
3132
} from '../../ui';
3233
import ShareResults from '../../ui/ShareResults';
3334

@@ -146,38 +147,18 @@ export default function PetCost() {
146147
</div>
147148

148149
{/* Premium Food */}
149-
<div className="flex items-center gap-3">
150-
<button
151-
onClick={() => updateInput('premiumFood', !inputs.premiumFood)}
152-
className={`w-12 h-6 rounded-full transition-all ${
153-
inputs.premiumFood ? 'bg-amber-500' : 'bg-white/20'
154-
}`}
155-
>
156-
<div
157-
className={`w-5 h-5 rounded-full bg-white transition-transform ${
158-
inputs.premiumFood ? 'translate-x-6' : 'translate-x-0.5'
159-
}`}
160-
/>
161-
</button>
162-
<span className="text-[var(--color-cream)]">Premium Food (+50%)</span>
163-
</div>
150+
<Toggle
151+
checked={inputs.premiumFood}
152+
onChange={(checked) => updateInput('premiumFood', checked)}
153+
label="Premium Food (+50%)"
154+
/>
164155

165156
{/* Pet Insurance */}
166-
<div className="flex items-center gap-3">
167-
<button
168-
onClick={() => updateInput('hasPetInsurance', !inputs.hasPetInsurance)}
169-
className={`w-12 h-6 rounded-full transition-all ${
170-
inputs.hasPetInsurance ? 'bg-amber-500' : 'bg-white/20'
171-
}`}
172-
>
173-
<div
174-
className={`w-5 h-5 rounded-full bg-white transition-transform ${
175-
inputs.hasPetInsurance ? 'translate-x-6' : 'translate-x-0.5'
176-
}`}
177-
/>
178-
</button>
179-
<span className="text-[var(--color-cream)]">Pet Insurance</span>
180-
</div>
157+
<Toggle
158+
checked={inputs.hasPetInsurance}
159+
onChange={(checked) => updateInput('hasPetInsurance', checked)}
160+
label="Pet Insurance"
161+
/>
181162

182163
<Divider />
183164

@@ -206,21 +187,11 @@ export default function PetCost() {
206187
{/* Daycare (dogs only) */}
207188
{inputs.petType === 'dog' && (
208189
<>
209-
<div className="flex items-center gap-3">
210-
<button
211-
onClick={() => updateInput('useDaycare', !inputs.useDaycare)}
212-
className={`w-12 h-6 rounded-full transition-all ${
213-
inputs.useDaycare ? 'bg-amber-500' : 'bg-white/20'
214-
}`}
215-
>
216-
<div
217-
className={`w-5 h-5 rounded-full bg-white transition-transform ${
218-
inputs.useDaycare ? 'translate-x-6' : 'translate-x-0.5'
219-
}`}
220-
/>
221-
</button>
222-
<span className="text-[var(--color-cream)]">Use Daycare</span>
223-
</div>
190+
<Toggle
191+
checked={inputs.useDaycare}
192+
onChange={(checked) => updateInput('useDaycare', checked)}
193+
label="Use Daycare"
194+
/>
224195
{inputs.useDaycare && (
225196
<Slider
226197
label="Daycare Days per Month"
@@ -242,21 +213,11 @@ export default function PetCost() {
242213
{/* Boarding */}
243214
{inputs.petType !== 'fish' && (
244215
<>
245-
<div className="flex items-center gap-3">
246-
<button
247-
onClick={() => updateInput('useBoarding', !inputs.useBoarding)}
248-
className={`w-12 h-6 rounded-full transition-all ${
249-
inputs.useBoarding ? 'bg-amber-500' : 'bg-white/20'
250-
}`}
251-
>
252-
<div
253-
className={`w-5 h-5 rounded-full bg-white transition-transform ${
254-
inputs.useBoarding ? 'translate-x-6' : 'translate-x-0.5'
255-
}`}
256-
/>
257-
</button>
258-
<span className="text-[var(--color-cream)]">Boarding/Pet Sitting</span>
259-
</div>
216+
<Toggle
217+
checked={inputs.useBoarding}
218+
onChange={(checked) => updateInput('useBoarding', checked)}
219+
label="Boarding/Pet Sitting"
220+
/>
260221
{inputs.useBoarding && (
261222
<Slider
262223
label="Weeks of Boarding per Year"

src/components/ui/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export type { SliderProps } from './primitives/Slider';
6969
export { Checkbox } from './primitives/Checkbox';
7070
export type { CheckboxProps } from './primitives/Checkbox';
7171

72+
export { Toggle } from './primitives/Toggle';
73+
export type { ToggleProps } from './primitives/Toggle';
74+
7275
// Layout
7376
export { Card } from './layout/Card';
7477
export type { CardProps } from './layout/Card';

src/components/ui/primitives/ButtonGroup.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useId } from 'preact/hooks';
12
import { useTheme } from '../theme/ThemeContext';
23

34
export interface ButtonGroupOption<T = string> {
@@ -16,6 +17,8 @@ export interface ButtonGroupProps<T = string> {
1617
columns?: 2 | 3 | 4;
1718
/** Size variant */
1819
size?: 'sm' | 'md' | 'lg';
20+
/** Accessible label for the group */
21+
'aria-label'?: string;
1922
/** Additional class names */
2023
className?: string;
2124
}
@@ -54,21 +57,49 @@ export function ButtonGroup<T extends string>({
5457
onChange,
5558
columns,
5659
size = 'md',
60+
'aria-label': ariaLabel,
5761
className = '',
5862
}: ButtonGroupProps<T>) {
5963
const { tokens } = useTheme();
64+
const groupId = useId();
6065

6166
const cols = columns || ((options.length <= 4 ? options.length : 3) as 2 | 3 | 4);
6267

68+
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
69+
let newIndex = index;
70+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
71+
e.preventDefault();
72+
newIndex = (index + 1) % options.length;
73+
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
74+
e.preventDefault();
75+
newIndex = (index - 1 + options.length) % options.length;
76+
}
77+
if (newIndex !== index) {
78+
onChange(options[newIndex].value);
79+
// Focus the new button
80+
const btn = document.getElementById(`${groupId}-${newIndex}`);
81+
btn?.focus();
82+
}
83+
};
84+
6385
return (
64-
<div className={`grid ${GRID_COLS[cols]} gap-3 ${className}`}>
65-
{options.map((option) => {
86+
<div
87+
role="radiogroup"
88+
aria-label={ariaLabel}
89+
className={`grid ${GRID_COLS[cols]} gap-3 ${className}`}
90+
>
91+
{options.map((option, index) => {
6692
const isActive = option.value === value;
6793
return (
6894
<button
95+
id={`${groupId}-${index}`}
6996
key={String(option.value)}
7097
type="button"
98+
role="radio"
99+
aria-checked={isActive}
100+
tabIndex={isActive ? 0 : -1}
71101
onClick={() => onChange(option.value)}
102+
onKeyDown={(e) => handleKeyDown(e, index)}
72103
className={`
73104
${SIZE_CLASSES[size]}
74105
rounded-xl border-2 font-medium transition-all

0 commit comments

Comments
 (0)