Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/text-input-description-aria-describedby-5735.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>, schemaExtra: Record<string, unknown> = {}) {
const C = ComponentRegistry.get('element:text_input') as React.ComponentType<any>;
if (!C) throw new Error('element:text_input is not registered');
return render(<C schema={{ type: 'element:text_input', id: 'ws_input', properties, ...schemaExtra }} />);
}

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<any>;
render(<C schema={{ type: 'element:text_input', properties: { label: 'Workspace', description: HELP } }} />);

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<any>;
render(
<>
<C schema={{ type: 'element:text_input', id: 'dup', properties: { label: 'First', description: 'First help.' } }} />
<C schema={{ type: 'element:text_input', id: 'dup', properties: { label: 'Second', description: 'Second help.' } }} />
</>,
);

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<any>;
render(
<I18nProvider persistLanguage={false} config={{ defaultLanguage: 'zh-CN', detectBrowserLanguage: false }}>
<C
schema={{
type: 'element:text_input',
id: 'ws_input',
properties: { label: 'Workspace', description: { en: 'Owner', 'zh-CN': '负责人' } },
}}
/>
</I18nProvider>,
);

const input = screen.getByRole('textbox');
expect(describedElements(input)[0].textContent).toBe('负责人');
expect(input).toHaveAccessibleDescription('负责人');
});

it('survives the real render path through SchemaRenderer', () => {
render(
<SchemaRenderer
schema={{ type: 'element:text_input', id: 'ws_input', properties: { label: 'Workspace', description: HELP } }}
/>,
);

const input = screen.getByRole('textbox');
expect(describedElements(input)[0].textContent).toBe(HELP);
expect(input).toHaveAccessibleDescription(HELP);
});
});
49 changes: 47 additions & 2 deletions packages/components/src/renderers/basic/text-input.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 `<FormItem>`
// 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 (
<div
className={cn('grid w-full max-w-sm items-center gap-1.5', schema?.className)}
Expand All@@ -119,9 +146,14 @@ function ElementTextInputRenderer({ schema }: { schema: any }) {
defaultValue={value === undefined ? (props.defaultValue as any) : undefined}
required={props.required}
disabled={props.disabled}
aria-describedby={descriptionId}
onChange={handleChange}
/>
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{description && (
<p id={descriptionId} className="text-sm text-muted-foreground">
{description}
</p>
)}
</div>
);
}
Expand DownExpand Up@@ -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 `<p>` — a different destination from `label` (above, in a `<label>`) and `placeholder` (inside the field), reached by the same read path. Display-only, and OMITTED entirely when the key is absent or resolves to an empty string. Accepts either a plain string or an inline per-locale map (`{ en: "Owner", "zh-CN": "负责人" }`), resolved against the active language with the same fallback chain as `label`. Presentational only: the renderer renders it as a sibling paragraph and does not tie it to the field with `aria-describedby`, so a screen reader does not announce it together with the input — instructions a user must not miss belong in `label`.',
'Helper text rendered BELOW the input, in its own `<p>` — a different destination from `label` (above, in a `<label>`) and `placeholder` (inside the field), reached by the same read path. Display-only, and OMITTED entirely when the key is absent or resolves to an empty string. Accepts either a plain string or an inline per-locale map (`{ en: "Owner", "zh-CN": "负责人" }`), resolved against the active language with the same fallback chain as `label`. The paragraph IS tied to the field with `aria-describedby`, so the resolved text is the input’s accessible DESCRIPTION and assistive tech announces it with the field rather than leaving it as unreachable decoration. That association does not depend on the node carrying an `id` (`label`’s `htmlFor` does): the id `aria-describedby` needs sits on the paragraph, which the renderer mints per instance, and it is emitted only when a paragraph is actually rendered — an absent or empty `description` leaves the input with no `aria-describedby` at all. Prefer `label` anyway for an instruction a user MUST NOT miss: a description is announced after the field’s name, and screen readers gate description text behind verbosity settings a user can turn down (NVDA’s “Report object descriptions”, VoiceOver hint verbosity), so it is the half of the announcement most likely to go unheard — the same advice as before, now resting on announcement order and verbosity rather than on the text being unwired.',
},
],
});
Expand Down
Loading