diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index fbf2c26448..e365049467 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -889,3 +889,12 @@ The outer animated surface owns both paint and backdrop blur; its content is transparent and unfiltered. An opaque fallback covers unsupported filters and reduced-transparency preferences. Tooltip declares ownership of its entrance motion to avoid the product's additional popup animation. + +### Layout-owned field labels + +`FieldAccessibilityProvider` accepts `aria-labelledby` and `aria-describedby` +references for layouts that render labels outside their controls. Input, Switch, +Checkbox, Select, Combobox, MultiSelect, NumberInput, SearchField and Textarea +consume those references through intervening wrappers. A control's explicit name +or own label takes precedence; descriptions are combined. Give each child in a +composite field its own name. Nested `Field` controls retain their own labels. diff --git a/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx b/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx index 8abf641203..615a16ad50 100644 --- a/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -1,3 +1,4 @@ +import { useFieldAccessibility } from "../Field/FieldAccessibility"; import { Check as LucideCheck, Minus as LucideMinus } from 'lucide-react'; import { forwardRef, @@ -44,6 +45,7 @@ export const Checkbox = forwardRef(function Che size = "md", ...props }, forwardedRef) { + const fieldAccessibility = useFieldAccessibility(props, label !== undefined || children !== undefined); const inputRef = useRef(null); useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement); @@ -67,6 +69,7 @@ export const Checkbox = forwardRef(function Che (function Collec const activeDescendant = resolvedOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined; + const fieldAccessibility = useFieldAccessibility({ ...props, "aria-describedby": ariaDescribedBy }, Boolean(label)); const resolvedTriggerLabel = ariaLabel - ?? (label || providedId || typeof placeholder !== "string" ? undefined : placeholder); + ?? (label || fieldAccessibility["aria-labelledby"] || providedId || typeof placeholder !== "string" ? undefined : placeholder); const resolvedLabelledBy = ariaLabel ? ariaLabelledBy - : ariaLabelledBy ?? (label ? labelId : undefined); + : ariaLabelledBy ?? (label ? labelId : fieldAccessibility["aria-labelledby"]); const resolvedDescribedBy = [ - ariaDescribedBy, + fieldAccessibility["aria-describedby"], errorMessage ? errorId : undefined, ].filter(Boolean).join(" ") || undefined; diff --git a/design-system/packages/ui/src/components/Field/Field.tsx b/design-system/packages/ui/src/components/Field/Field.tsx index e6b40b3ef0..97c737f51e 100644 --- a/design-system/packages/ui/src/components/Field/Field.tsx +++ b/design-system/packages/ui/src/components/Field/Field.tsx @@ -1,3 +1,4 @@ +import { FieldAccessibilityProvider } from "./FieldAccessibility"; import { cloneElement, forwardRef, @@ -112,7 +113,7 @@ export const Field = forwardRef(function Field({ {controlLeading} )} - {control} + {control} {controlTrailing !== undefined && controlTrailing !== null && ( {controlTrailing} diff --git a/design-system/packages/ui/src/components/Field/FieldAccessibility.tsx b/design-system/packages/ui/src/components/Field/FieldAccessibility.tsx new file mode 100644 index 0000000000..344e50fb27 --- /dev/null +++ b/design-system/packages/ui/src/components/Field/FieldAccessibility.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type AriaAttributes, type ReactNode } from "react"; + +type FieldAccessibility = Pick; +const FieldAccessibilityContext = createContext({}); + +/** Associates a layout-owned label with controls, including controls inside wrappers. + * Composite fields should give each child its own accessible name. + */ +export function FieldAccessibilityProvider({ children, ...value }: FieldAccessibility & { children: ReactNode }) { + return {children}; +} + +export function useFieldAccessibility(props: AriaAttributes, hasOwnLabel = false): FieldAccessibility { + const inherited = useContext(FieldAccessibilityContext); + return { + "aria-labelledby": props["aria-labelledby"] ?? ( + props["aria-label"] || hasOwnLabel ? undefined : inherited["aria-labelledby"] + ), + "aria-describedby": [...new Set([ + props["aria-describedby"], inherited["aria-describedby"], + ].filter(Boolean).flatMap(value => value!.split(/\s+/)))].join(" ") || undefined, + }; +} diff --git a/design-system/packages/ui/src/components/Input/Input.tsx b/design-system/packages/ui/src/components/Input/Input.tsx index 772961a6a8..2602a686ca 100644 --- a/design-system/packages/ui/src/components/Input/Input.tsx +++ b/design-system/packages/ui/src/components/Input/Input.tsx @@ -1,3 +1,4 @@ +import { useFieldAccessibility } from "../Field/FieldAccessibility"; import { forwardRef, useRef, @@ -41,6 +42,7 @@ export const Input = forwardRef(function Input({ type = "text", ...props }, ref) { + const fieldAccessibility = useFieldAccessibility(props); const compositionActiveRef = useRef(false); const fieldSurface = useFieldSurface(); const handleChange: ChangeEventHandler = (event) => { @@ -67,6 +69,7 @@ export const Input = forwardRef(function Input({ )} (functi "aria-invalid": ariaInvalid, }, ref) { const fieldSurface = useFieldSurface(); + const fieldAccessibility = useFieldAccessibility({ + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + }, Boolean(label)); const format = useCallback((next: number) => precision > 0 ? next.toFixed(precision) : String(Math.round(next)), [precision]); const clamp = useCallback((next: number) => Math.min(max, Math.max(min, next)), [max, min]); const [draft, setDraft] = useState(() => format(value)); @@ -106,8 +112,8 @@ export const NumberInput = forwardRef(functi (function Select }, forwardedRef) { const designSystem = useDesignSystem(); const fieldSurface = useFieldSurface(); + const fieldAccessibility = useFieldAccessibility({ + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + }); const generatedId = useId(); const id = providedId ?? `openbitfun-select-${generatedId}`; const listboxId = `${id}-listbox`; @@ -390,12 +396,12 @@ export const Select = forwardRef(function Select