From 5d49cf526e785278601d4517be840ea647afd71c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 06:11:43 +0000 Subject: [PATCH 1/2] fix(fields): deliver slider's a11y channels, name signature, split the aria-invalid ledger (#3318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry sweep's NOT_YET_DELIVERED ledger held nine rows. Measured against `origin/main` before touching anything — a real form, one required field per row, freshly failed validation: formula ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 summary ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 auto_number ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 vector ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 signature ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 filter-condition ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 recipient-picker ariaInvalidTrue=[] focusable=0 labelFor=DANGLING desc=0 grid ariaInvalidTrue=[] focusable=1 [button] slider ariaInvalidTrue=[] focusable=2 [span[slider],input] text (control) ariaInvalidTrue=[input] focusable=1 labelFor -> input desc=1 Three dispositions, and the measurement is what separates them. DELIVERED — slider. Its focusable control is Radix's span[role="slider"] thumb, rendered inside the synced `ui/slider.tsx` and not exported, so unlike #3306's SelectTrigger a widget has no handle on it. The primitive grew a declared `thumbProps`, routed by `lib/slider-thumb.ts` and applied to the no-touch file through the repo's declared sync-patch registry so it survives regeneration. The root half of that patch is load-bearing, not bookkeeping: without it the object stringifies onto the wrapper and the host id lands on two elements at once. `SliderField` then splits exactly as `SelectField` does — `name` and `disabled` stay on Root, everything else goes to the thumb, `aria-invalid` after the spread. One fix, three channels: state, name and description. NOT APPLICABLE — a second ledger, for the seven rows whose widget renders no focusable control at all. `aria-invalid` is control-channel state; a row with nothing to focus has no element to carry it, and marking its text span is the wrapper move this sweep exists to forbid. Each row states its ground and the sweep MEASURES that ground, so a reclassification is falsifiable: `grid` cannot be parked there to empty the first ledger, because it renders a button. Left in NOT_YET_DELIVERED: `grid` alone, where driving a form-level failure into a per-cell editor needs a design rather than a spread. Signature's verdict is split, not blanket. The control state does not apply, but the NAME always did: `` is not a labelable element, so its host label could only dangle. It and `slider` are now declared `labelling: 'group'` — `file`'s shape, one unlabelable control, not a composite — and named by IDREF. Co-authored-by: Claude --- .../slider-signature-host-aria-channels.md | 36 +++ packages/components/src/lib/slider-thumb.ts | 98 ++++++ packages/components/src/ui/slider.tsx | 7 +- .../group-labelling-declaration.test.ts | 7 + ...lider-signature-host-channels-e2e.test.tsx | 295 ++++++++++++++++++ .../widget-aria-invalid-registry-e2e.test.tsx | 234 +++++++++++--- packages/fields/src/index.tsx | 7 + .../fields/src/widgets/SignatureField.tsx | 50 ++- packages/fields/src/widgets/SliderField.tsx | 70 ++++- .../__tests__/shadcn-local-patches.test.ts | 90 ++++++ scripts/shadcn-local-patches.mjs | 82 +++++ 11 files changed, 921 insertions(+), 55 deletions(-) create mode 100644 .changeset/slider-signature-host-aria-channels.md create mode 100644 packages/components/src/lib/slider-thumb.ts create mode 100644 packages/fields/src/__tests__/slider-signature-host-channels-e2e.test.tsx 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 `