diff --git a/.changeset/text-input-description-aria-describedby-5735.md b/.changeset/text-input-description-aria-describedby-5735.md new file mode 100644 index 000000000..af1dc6abe --- /dev/null +++ b/.changeset/text-input-description-aria-describedby-5735.md @@ -0,0 +1,35 @@ +--- +'@object-ui/components': patch +--- + +`element:text_input` now ties its authored `description` to the field with +`aria-describedby`, so assistive tech announces the helper text as the input's +accessible description instead of leaving it as an unassociated paragraph +beside the field (objectui#5735). + +Before this the paragraph and the input were siblings with no programmatic +relationship: a screen reader moving to the field announced the label and the +value and never the helper text. The `label` half of the same block was already +wired (`htmlFor` against the input's `id`), which is what made the gap specific +to `description` rather than a general absence of a11y wiring — and the +identical key authored on a field INSIDE `renderers/form/form.tsx` has been +announced all along, so one authoring key behaved two ways depending on which +container the author reached for. It no longer does. + +The paragraph's id is minted per instance with `React.useId()` — the same source +`FormItem` mints the form renderer's description id from — and deliberately not +derived from `schema.id`. The two associations in this block need ids on +opposite ends: `htmlFor` names the INPUT, whose id only the author can supply, +so that wiring still holds only when they gave the node an `id`; `aria-describedby` +names the PARAGRAPH, which the renderer owns, so the description association +holds unconditionally and cannot collide when two nodes share an authored id. +The attribute is emitted only when a paragraph is actually rendered — an absent +or empty `description` leaves the input with no `aria-describedby` rather than a +dangling reference. + +The key's published `ComponentInput` description, which documented this gap in +so many words, is rewritten in the same change. Its closing advice — prefer +`label` for an instruction a user must not miss — is kept rather than deleted, +on a new basis: a description is announced after the field's name and screen +readers gate description text behind verbosity settings a user can turn down, so +it remains the half of the announcement most likely to go unheard. diff --git a/packages/components/src/__tests__/text-input-description-association.test.tsx b/packages/components/src/__tests__/text-input-description-association.test.tsx new file mode 100644 index 000000000..5f810d1fc --- /dev/null +++ b/packages/components/src/__tests__/text-input-description-association.test.tsx @@ -0,0 +1,240 @@ +/** + * 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. + * + * `element:text_input` — the authored `description` is the field's accessible + * DESCRIPTION, not an unassociated paragraph beside it (objectui#5735). + * + * ## What these tests pin, and what they deliberately do NOT + * + * The defect was never "the paragraph has no id". It was that the paragraph and + * the input had no programmatic RELATIONSHIP: a screen reader moving to the + * field announced the label and the value and never the helper text. So every + * case below asserts the relationship end to end — read the control's + * `aria-describedby`, look each id up in the document, and compare the resolved + * element's text — via the `describedElements` helper. Two assertions that + * checked the attributes separately ("the p has an id", "the input has an + * aria-describedby") both pass on a build where the two point at DIFFERENT + * things, which is the build this file has to be able to fail on. + * + * ## What the instrument can and cannot discriminate + * + * `toHaveAccessibleDescription` runs `dom-accessibility-api` over happy-dom. It + * can prove the description is COMPUTED — that the reference resolves and the + * resolved text is what an AT would be handed. It cannot prove any screen + * reader SPEAKS it: description text is announced after the accessible name and + * is gated by AT verbosity settings (NVDA's "Report object descriptions", + * VoiceOver hint verbosity), and nothing in this repo can measure that. This is + * why the renderer's own `description` prose keeps the "prefer `label` for + * instructions a user must not miss" advice after the wiring landed — the + * advice now rests on announcement order and verbosity, which is cited, rather + * than on the text being unreachable, which was measured and is now false. + * + * Second limit, worth stating because it is the one that could mislead: CSS + * generated content does not exist in happy-dom, so the `required` asterisk + * (`after:content-['*']` on the `Label`) is invisible to every name/description + * computation here. It IS part of the accessible name in a real browser. No + * case below depends on that either way. + * + * ## Why the renderer is driven through the registry, not `SchemaRenderer` + * + * Same reason `text-input-i18n-label-arms.test.tsx` next door states: + * `SchemaRenderer` injects its own props around a renderer, so a case driven + * through it can be green for a reason that is not the renderer's. The last + * case re-runs the primary relationship through `SchemaRenderer` on purpose — + * that is the path an authored page actually takes, and it is worth one + * assertion that the wrapper does not strip the wiring. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRenderer } from '@object-ui/react'; +// Registers `element:text_input` at module scope, not in a hook +// (object-ui/no-dynamic-import-in-test-hook, objectui#3010). +import '../renderers'; + +afterEach(cleanup); + +const HELP = 'Lowercase letters and dashes only.'; + +/** + * The relationship, resolved the way assistive tech resolves it: read + * `aria-describedby`, split it, and look every id up in the document. + * + * A dangling id THROWS rather than being skipped. That is the point — an + * `aria-describedby` naming an element that does not exist is worse than no + * attribute, and a helper that quietly dropped the miss would let exactly that + * build pass. + */ +function describedElements(control: Element): HTMLElement[] { + const ref = control.getAttribute('aria-describedby'); + if (ref == null) return []; + return ref + .split(/\s+/) + .filter(Boolean) + .map((id) => { + const el = document.getElementById(id); + if (!el) { + throw new Error(`aria-describedby names "${id}", but no element in the document has that id`); + } + return el; + }); +} + +function renderInput(properties: Record, schemaExtra: Record = {}) { + const C = ComponentRegistry.get('element:text_input') as React.ComponentType; + if (!C) throw new Error('element:text_input is not registered'); + return render(); +} + +describe('element:text_input — description is the field\'s accessible description (objectui#5735)', () => { + it('resolves the input aria-describedby to the paragraph carrying the resolved description', () => { + renderInput({ label: 'Workspace', description: HELP }); + + const input = screen.getByRole('textbox'); + const described = describedElements(input); + + // ONE description carrier, and it is the rendered paragraph — asserted as a + // resolution, not as two independent attributes. + expect(described).toHaveLength(1); + expect(described[0].tagName).toBe('P'); + expect(described[0].textContent).toBe(HELP); + expect(described[0]).toBe(screen.getByText(HELP)); + + // And the computed description an AT would be handed. + expect(input).toHaveAccessibleDescription(HELP); + }); + + it('emits NO aria-describedby when no description is authored', () => { + renderInput({ label: 'Workspace' }); + + const input = screen.getByRole('textbox'); + // The control that keeps the fix honest: a renderer that ALWAYS emitted an + // `aria-describedby` would satisfy every other case in this file while + // publishing a dangling reference on every description-less field. + expect(input).not.toHaveAttribute('aria-describedby'); + expect(describedElements(input)).toHaveLength(0); + expect(input).toHaveAccessibleDescription(''); + expect(document.querySelector('p')).toBeNull(); + }); + + it('emits no aria-describedby for a description that resolves to an empty string', () => { + // The paragraph is dropped by the same `{description && …}` truthiness the + // id is minted under, so the two cannot drift apart into an attribute + // pointing at a paragraph that was never rendered. + renderInput({ label: 'Workspace', description: '' }); + + const input = screen.getByRole('textbox'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(document.querySelector('p')).toBeNull(); + }); + + it('keeps the label association working — it shares the id most likely to break', () => { + renderInput({ label: 'Workspace', description: HELP }); + + const input = screen.getByRole('textbox'); + expect(screen.getByLabelText('Workspace')).toBe(input); + expect(input).toHaveAccessibleName('Workspace'); + // Name and description are separate channels: the helper text must not have + // leaked into the name. + expect(input).toHaveAccessibleDescription(HELP); + expect(document.querySelector('label')).toHaveAttribute('for', 'ws_input'); + expect(input).toHaveAttribute('id', 'ws_input'); + }); + + it('associates the description even when the node carries NO id — while the label degrades exactly as before', () => { + // The deliberate half of the fix. `htmlFor` needs an id on the INPUT, which + // only the author can supply, so the label wiring can only hold when they + // did — unchanged here. `aria-describedby` needs an id on the PARAGRAPH, + // which the renderer mints, so the description association never depended + // on the author at all. + const C = ComponentRegistry.get('element:text_input') as React.ComponentType; + render(); + + const input = screen.getByRole('textbox'); + + // Pre-existing behaviour, pinned so a later "fix" cannot change it silently. + expect(input).not.toHaveAttribute('id'); + expect(document.querySelector('label')).not.toHaveAttribute('for'); + expect(input).toHaveAccessibleName(''); + + // …and the description is associated anyway. + const described = describedElements(input); + expect(described).toHaveLength(1); + expect(described[0].textContent).toBe(HELP); + expect(input).toHaveAccessibleDescription(HELP); + }); + + it('gives each input its OWN paragraph even when two nodes share an authored id', () => { + // The case that discriminates the chosen mechanism from the obvious + // alternative. Deriving the paragraph id from `schema.id` would publish two + // paragraphs with the same id here, and BOTH fields would resolve to + // whichever came first in the document — the wrong helper text announced on + // one of them, which is worse than none. A per-instance `React.useId()` + // cannot collide. This pins the property, not an endorsement of duplicate + // authored ids. + const C = ComponentRegistry.get('element:text_input') as React.ComponentType; + render( + <> + + + , + ); + + const [first, second] = screen.getAllByRole('textbox'); + expect(first.getAttribute('aria-describedby')).not.toBe(second.getAttribute('aria-describedby')); + expect(describedElements(first)[0].textContent).toBe('First help.'); + expect(describedElements(second)[0].textContent).toBe('Second help.'); + expect(first).toHaveAccessibleDescription('First help.'); + expect(second).toHaveAccessibleDescription('Second help.'); + }); + + it('describes the field from the description, not from the placeholder', () => { + renderInput({ label: 'Workspace', placeholder: 'acme', description: HELP }); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('placeholder', 'acme'); + expect(input).toHaveAccessibleDescription(HELP); + expect(describedElements(input)[0].textContent).toBe(HELP); + }); + + it('associates the RESOLVED locale value, not the raw map', () => { + // `description` accepts an inline per-locale map (objectui#5717). What must + // reach `aria-describedby` is the value `pickLocalized` resolved for the + // active language — a build that associated the paragraph but rendered the + // map, or resolved the map but associated something else, is red here. + const C = ComponentRegistry.get('element:text_input') as React.ComponentType; + render( + + + , + ); + + const input = screen.getByRole('textbox'); + expect(describedElements(input)[0].textContent).toBe('负责人'); + expect(input).toHaveAccessibleDescription('负责人'); + }); + + it('survives the real render path through SchemaRenderer', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + expect(describedElements(input)[0].textContent).toBe(HELP); + expect(input).toHaveAccessibleDescription(HELP); + }); +}); diff --git a/packages/components/src/renderers/basic/text-input.tsx b/packages/components/src/renderers/basic/text-input.tsx index 53551e539..25a2ce075 100644 --- a/packages/components/src/renderers/basic/text-input.tsx +++ b/packages/components/src/renderers/basic/text-input.tsx @@ -97,6 +97,33 @@ function ElementTextInputRenderer({ schema }: { schema: any }) { const placeholder = pickLocalized(props.placeholder, language); const description = pickLocalized(props.description, language); + // The description paragraph's id is MINTED HERE, and deliberately NOT derived + // from `schema.id` — the two associations in this block need ids on opposite + // ends and therefore do not share a dependency: + // + // - `label`'s `htmlFor` must name the INPUT, whose id is the author's + // `schema.id` (the same key `usePageVariableBinding` binds on). Only the + // author can supply it, so that wiring can only hold when they did. + // - `aria-describedby` names the PARAGRAPH, an element this renderer wholly + // owns and that no author ever addresses. Nothing about it depends on the + // node carrying an `id`, so the association holds unconditionally. + // + // Reusing `schema.id` would have imported the label's dependency for no gain + // and added a failure the label wiring cannot have: two inputs sharing an id + // would publish two paragraphs sharing an id, and both fields' + // `aria-describedby` would resolve to whichever came first in the document — + // the WRONG helper text announced, which is worse than none. `React.useId()` + // is per instance and SSR-stable, and it is the same source `` + // mints the form renderer's `…-form-item-description` from (`ui/form.tsx`), + // so the standalone element and the form container now reach the same shape + // by the same route. + const instanceId = React.useId(); + // Emitted ONLY when a paragraph is actually rendered. An `aria-describedby` + // that outlives an absent description is a DANGLING reference — worse than + // no attribute, because assistive tech reports the broken id rather than + // falling through to whatever else could describe the field. + const descriptionId = description ? `${instanceId}-description` : undefined; + return (
- {description &&

{description}

} + {description && ( +

+ {description} +

+ )}
); } @@ -246,10 +278,23 @@ ComponentRegistry.register('text_input', ElementTextInputRenderer, { // destination-based split would have declared an arm on one key and // withheld it on another for a difference neither the gate nor the // contract can see. + // + // The a11y sentence at the END of this description is PAIRED with the + // render site above and must move with it. It previously documented the + // gap ("does not tie it to the field with `aria-describedby`") because + // that was true; the wiring landed with objectui#5735 and the sentence + // was rewritten in the same change. The trailing "prefer `label`" advice + // was kept, not deleted: it was ORIGINALLY true because the text was not + // exposed at all, and it is STILL true for a different and weaker reason + // — a description is announced after the accessible name and is gated by + // AT verbosity settings a user can turn down. That reason is CITED, not + // measured here: the tests can prove the accessible description is + // computed and non-empty, and no test in this repo can prove what any + // screen reader speaks in any given verbosity mode. type: ['string', 'object'], label: 'Description', description: - 'Helper text rendered BELOW the input, in its own `

` — a different destination from `label` (above, in a `

` — a different destination from `label` (above, in a `