diff --git a/.changeset/slider-signature-host-aria-channels.md b/.changeset/slider-signature-host-aria-channels.md new file mode 100644 index 0000000000..d732501bd9 --- /dev/null +++ b/.changeset/slider-signature-host-aria-channels.md @@ -0,0 +1,36 @@ +--- +"@object-ui/components": patch +"@object-ui/fields": patch +--- + +fix(fields): deliver the host's a11y channels to `slider` and name `signature` + +`SliderField` and `SignatureField` forwarded nothing a form host handed them — +neither spread `toDomProps(props)` at all — so ``'s whole payload +landed on nothing. Measured on a real form, one required field per row, freshly +failed validation: + +``` +slider ariaInvalidTrue=[] labelFor=…-form-item -> DANGLING descConsumers=0 ids=[] +signature ariaInvalidTrue=[] labelFor=…-form-item -> DANGLING descConsumers=0 ids=[] +text ariaInvalidTrue=[input] labelFor -> input descConsumers=1 +``` + +`ids=[]` is the tell: no element in either row carried an id at all, so the +visible label pointed `for` at nothing, the rendered help text had zero +consumers, and a failed slider announced no error state. + +**`slider`** now delivers all three. Its focusable control is Radix's +`span[role="slider"]` thumb, which the synced `ui/slider.tsx` renders internally +and does not export, so the primitive grew a declared `thumbProps` — routed +through a new `lib/slider-thumb` and applied to the no-touch file as a declared +sync patch, so it survives regeneration. The split of which keys stay on Root +(`name`, `disabled`) is the one the `select` fix already settled. + +**`signature`** gets the name and the description on a `role="group"` container. +Its control state deliberately does not follow: the drawing surface is a +`` with no keyboard path, and its only other element is disabled while +the pad is empty, so there is no element a control state could be read from. + +Both are now declared `labelling: 'group'` — a `` and a `` are not +labelable elements, so a host `for` could only dangle at them. diff --git a/packages/components/src/lib/slider-thumb.ts b/packages/components/src/lib/slider-thumb.ts new file mode 100644 index 0000000000..c225cb7c9b --- /dev/null +++ b/packages/components/src/lib/slider-thumb.ts @@ -0,0 +1,98 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type * as React from 'react'; + +/** + * The payload of the `slider-thumb-pass-through` local patch + * (`scripts/shadcn-local-patches.mjs`, objectui#3318). + * + * ## The defect this exists for + * + * A Radix slider's focusable control is the THUMB — `span[role="slider"]`, + * `tabindex="0"`, the element that carries `aria-valuenow` and the one a + * keyboard user and their screen reader actually land on. `Slider.Root` renders + * a role-less wrapper `span` and forwards every unrecognised prop to it, so a + * host's control-channel facts (`id`, `aria-invalid`, `aria-describedby`, + * `aria-labelledby`, `aria-required`) landed on a wrapper assistive technology + * never visits. Measured on `origin/main`, a real form + a required slider that + * had just failed validation: + * + * ``` + * slider ariaInvalidTrue=[] labelFor=…-form-item -> DANGLING + * descConsumers=0 focusable=[span[slider],input] + * ids=[] <- no element of the row carried an id at all + * ``` + * + * This is the same shape objectui#3306 fixed for `select`, one step harder: + * there the focusable control is `SelectTrigger`, a component the primitive + * EXPORTS, so the widget could address it directly. `ui/slider.tsx` renders its + * thumb internally and exports only `Slider`, so the widget has no handle on + * the one element that must carry the state — hence a pass-through. + * + * ## Why a declared prop and not an implicit bridge + * + * The primitive could instead have COPIED a fixed list of `aria-*` keys from + * its own props down to the thumb. Rejected: the copy would leave the same keys + * on Root as well, and for `id` that is not a duplicate attribute but a + * duplicate DOM ID — `document.getElementById` answers with the wrapper, so a + * host `