Skip to content

Commit 21bbd31

Browse files
fix: Tier 2 audit — RadioGroup(75), FormInput(81), ComboBox(81), ColorInput(72), OtpInput(66)
RadioGroup: - Arrow key now selects on move (WAI-ARIA APG radio group pattern) - Removed redundant role="radiogroup" on inner div (fieldset suffices) - Removed redundant role="radio" on native radio inputs ComboBox: - aria-controls persists when listbox is closed (WAI-ARIA 1.2) - Added htmlFor on label element for native label association - Fixed premium wrapper: span→div to avoid inline wrapping block ColorInput: - Added SL area keyboard navigation (arrow keys adjust S/L by 1%) - Added focus management when popover opens - Added htmlFor on label element OtpInput: - Fixed autoFocus: useEffect approach instead of HTML attribute (reliable for conditionally-rendered components) - Lite digit aria-label now includes "of N" total count FormInput: - Fixed icon="search" string bug → <Icon name="search" size="sm" /> - Fixed Premium tier bundle size numbers in tier card Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent b713a10 commit 21bbd31

7 files changed

Lines changed: 70 additions & 12 deletions

File tree

demo/src/pages/components/FormInputPage.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1381,7 +1381,7 @@ export default function FormInputPage() {
13811381
label="Search"
13821382
placeholder="Type to search..."
13831383
clearable
1384-
icon="search"
1384+
icon={<Icon name="search" size="sm" />}
13851385
size="md"
13861386
/>
13871387
<InputComponent
@@ -1503,9 +1503,9 @@ export default function FormInputPage() {
15031503
</div>
15041504
<div className="form-input-page__size-breakdown">
15051505
<div className="form-input-page__size-row">
1506-
<span>Component: <strong style={{ color: 'var(--text-primary)' }}>2.0 KB</strong></span>
1506+
<span>Component: <strong style={{ color: 'var(--text-primary)' }}>~3.5 KB</strong></span>
15071507
<span>+ Shared: <strong style={{ color: 'var(--text-primary)' }}>0.9 KB</strong></span>
1508-
<span>= <strong style={{ color: 'var(--brand)' }}>2.9 KB</strong> gzip</span>
1508+
<span>= <strong style={{ color: 'var(--brand)' }}>4.4 KB</strong> gzip</span>
15091509
</div>
15101510
</div>
15111511
</div>

src/components/color-input.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,13 @@ export const ColorInput = forwardRef<HTMLDivElement, ColorInputProps>(
496496
[]
497497
)
498498

499+
// Focus the SL area when popover opens
500+
useEffect(() => {
501+
if (isOpen && slAreaRef.current) {
502+
slAreaRef.current.focus()
503+
}
504+
}, [isOpen])
505+
499506
// Close on click outside
500507
useEffect(() => {
501508
if (!isOpen) return
@@ -537,6 +544,48 @@ export const ColorInput = forwardRef<HTMLDivElement, ColorInputProps>(
537544
[saturation, lightness, updateColor]
538545
)
539546

547+
const handleSLKeyDown = useCallback(
548+
(e: KeyboardEvent<HTMLDivElement>) => {
549+
let newS = saturation
550+
let newL = lightness
551+
let handled = true
552+
553+
switch (e.key) {
554+
case 'ArrowRight':
555+
newS = Math.min(100, saturation + 1)
556+
break
557+
case 'ArrowLeft':
558+
newS = Math.max(0, saturation - 1)
559+
break
560+
case 'ArrowUp':
561+
newL = Math.min(100, lightness + 1)
562+
break
563+
case 'ArrowDown':
564+
newL = Math.max(0, lightness - 1)
565+
break
566+
case 'Home':
567+
newS = 0
568+
newL = 0
569+
break
570+
case 'End':
571+
newS = 100
572+
newL = 100
573+
break
574+
default:
575+
handled = false
576+
}
577+
578+
if (handled) {
579+
e.preventDefault()
580+
setSaturation(newS)
581+
setLightness(newL)
582+
const hex = hslToHex(hue, newS, newL)
583+
updateColor(hex)
584+
}
585+
},
586+
[saturation, lightness, hue, updateColor]
587+
)
588+
540589
const handleSLPointerDown = useCallback(
541590
(e: React.PointerEvent<HTMLDivElement>) => {
542591
if (disabled) return
@@ -599,7 +648,7 @@ export const ColorInput = forwardRef<HTMLDivElement, ColorInputProps>(
599648
{...rest}
600649
>
601650
{label && (
602-
<label className="ui-color-input__label">{label}</label>
651+
<label className="ui-color-input__label" htmlFor={`${stableId}-hex`}>{label}</label>
603652
)}
604653

605654
<div className="ui-color-input__row">
@@ -621,6 +670,7 @@ export const ColorInput = forwardRef<HTMLDivElement, ColorInputProps>(
621670
{showInput && (
622671
<input
623672
type="text"
673+
id={`${stableId}-hex`}
624674
className="ui-color-input__hex-input"
625675
name={name}
626676
value={hexInputValue}
@@ -650,6 +700,7 @@ export const ColorInput = forwardRef<HTMLDivElement, ColorInputProps>(
650700
className="ui-color-input__sl-area"
651701
style={{ background: slBackground }}
652702
onPointerDown={handleSLPointerDown}
703+
onKeyDown={handleSLKeyDown}
653704
role="slider"
654705
aria-label="Saturation and lightness"
655706
aria-valuetext={`Saturation ${saturation}%, Lightness ${lightness}%`}

src/components/combobox.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
486486
const motionLevel = useMotionLevel(motionProp)
487487
const id = useStableId('combobox')
488488
const listboxId = `${id}-listbox`
489+
const inputId = `${id}-input`
489490
const labelId = `${id}-label`
490491
const errorId = `${id}-error`
491492

@@ -765,7 +766,7 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
765766
{...rest}
766767
>
767768
{label && (
768-
<label className="ui-combobox__label" id={labelId}>
769+
<label className="ui-combobox__label" id={labelId} htmlFor={inputId}>
769770
{label}
770771
</label>
771772
)}
@@ -777,12 +778,13 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
777778
<div ref={wrapperRef} className="ui-combobox__input-wrapper">
778779
<input
779780
ref={inputRef}
781+
id={inputId}
780782
className="ui-combobox__input"
781783
type="text"
782784
role="combobox"
783785
aria-expanded={isOpen}
784786
aria-haspopup="listbox"
785-
aria-controls={isOpen ? listboxId : undefined}
787+
aria-controls={listboxId}
786788
aria-activedescendant={activeDescendantId}
787789
aria-autocomplete="list"
788790
aria-labelledby={label ? labelId : undefined}

src/components/otp-input.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
useState,
66
useRef,
77
useCallback,
8+
useEffect,
89
type HTMLAttributes,
910
type ChangeEvent,
1011
type KeyboardEvent,
@@ -204,6 +205,10 @@ export const OtpInput = forwardRef<HTMLDivElement, OtpInputProps>(
204205

205206
const digits = currentValue.split('').concat(Array(length).fill('')).slice(0, length)
206207

208+
useEffect(() => {
209+
if (autoFocus) inputRefs.current[0]?.focus()
210+
}, [autoFocus])
211+
207212
const updateValue = useCallback(
208213
(newValue: string) => {
209214
if (!isControlled) {
@@ -294,7 +299,6 @@ export const OtpInput = forwardRef<HTMLDivElement, OtpInputProps>(
294299
value={digit}
295300
maxLength={1}
296301
disabled={disabled}
297-
autoFocus={autoFocus && i === 0}
298302
autoComplete="one-time-code"
299303
aria-label={`Digit ${i + 1} of ${length}`}
300304
aria-invalid={error ? true : undefined}

src/components/radio-group.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,8 @@ export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
291291
items[currentIdx].setAttribute('tabindex', '-1')
292292
items[newIndex].setAttribute('tabindex', '0')
293293
items[newIndex].focus()
294+
// WAI-ARIA APG: arrow keys both move focus and select the radio
295+
items[newIndex].click()
294296
}
295297

296298
container.addEventListener('keydown', onKeyDown)
@@ -318,7 +320,7 @@ export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
318320
{...rest}
319321
>
320322
{label && <legend className="ui-radio-group__legend">{label}</legend>}
321-
<div className="ui-radio-group__options" ref={optionsRef} role="radiogroup">
323+
<div className="ui-radio-group__options" ref={optionsRef}>
322324
{options.map((option) => {
323325
const optionId = `${groupId}-${option.value}`
324326
const isChecked = currentValue === option.value
@@ -332,7 +334,6 @@ export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
332334
>
333335
<input
334336
type="radio"
335-
role="radio"
336337
id={optionId}
337338
name={name}
338339
value={option.value}

src/lite/otp-input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export const OtpInput = forwardRef<HTMLDivElement, LiteOtpInputProps>(
4747
maxLength={1}
4848
value={value[i] ?? ''}
4949
disabled={disabled}
50-
aria-label={`Digit ${i + 1}`}
50+
aria-label={`Digit ${i + 1} of ${length}`}
5151
onChange={e => handleInput(i, e.target.value)}
5252
onKeyDown={e => { if (e.key === 'Backspace' && !value[i] && i > 0) inputsRef.current[i - 1]?.focus() }}
5353
/>

src/premium/combobox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,9 @@ export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
8282
useStyles('premium-combobox', premiumComboboxStyles)
8383

8484
return (
85-
<span className="ui-premium-combobox" data-motion={motionLevel}>
85+
<div className="ui-premium-combobox" data-motion={motionLevel}>
8686
<BaseCombobox ref={ref} motion={motionProp} {...rest} />
87-
</span>
87+
</div>
8888
)
8989
}
9090
)

0 commit comments

Comments
 (0)