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
30 changes: 30 additions & 0 deletions .changeset/actionparamdialog-boolean-host-id-3962.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@object-ui/app-shell": patch
---

`ActionParamDialog` boolean params: the dialog now owns the control id, so the checkbox is named once instead of twice

The boolean branch rendered `<Label htmlFor={param.name}>` beside the control but
passed the widget no `id`, unlike its own generic branch a few dozen lines below,
which has always passed `id={param.name}`. Measured on a real dialog render, a
`boolean` param labelled "Confirm This" produced TWO label elements pointing at
one control — the dialog's visible one and a `sr-only` copy the widget emitted —
so the checkbox's accessible name was the two concatenated: screen readers
announced "Confirm This Confirm This".

Two distinct problems, one line apart:

- The association was IMPLICIT. It resolved only because `BooleanField`'s id
fallback chain reaches `config.name`, which `paramToField` seeds from
`param.name`, so both sides landed on the same string by coincidence of
another package's internals. A host that renders `htmlFor` must emit the id it
names; that is what the declared `id` key of the widget contract is for.
- The duplicate `sr-only` label. objectui#3952 / PR #3959 made `BooleanField`
suppress its own label whenever a host supplies the id — precisely because a
host that supplies an id is a host that renders a label. Receiving no id, this
branch never triggered that suppression.

Both `for` targets resolved, so unlike objectui#3341 and objectui#3952 this was
never a dangling label: clicking the row's text already toggled the control, and
still does. What changes is the announced name, which is now the single
"Confirm This" the author declared. The generic branch is untouched.
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,76 @@ describe('app-shell ActionParamDialog — `aria-required` reaches the widget con
expect(input.required).toBe(false);
});
});

/**
* The HOST owns the control id on the boolean branch too (objectui#3962).
*
* The boolean branch renders `<Label htmlFor={param.name}>` but used to pass no
* `id` to the widget, so the association only held because `BooleanField`'s
* fallback chain happened to reach the same string (`config.name`, which
* `paramToField` seeds from `param.name`). Two consequences, one of them a real
* defect:
*
* 1. The association was IMPLICIT — it lived in another package's fallback, not
* in this host's own output. The generic branch a few dozen lines below has
* always passed `id={param.name}`; this branch now matches it.
* 2. Receiving no host id, the widget could not know the host had already
* rendered a label, so it emitted its own `sr-only` one as well. Two label
* elements pointing at one control CONCATENATE into the accessible name
* (accname §2D), so the control announced "Confirm This Confirm This".
*
* Both `for` targets resolved, so this was never the dangling-label failure of
* #3341/#3952 — it was a duplicated name. The measurable half is therefore the
* NAME, not clickability: the pins below assert one label element and an
* un-doubled accessible name. `getAllByLabelText` deliberately is NOT the
* probe — it de-dupes its matches through a `Set` and splits multi-label names,
* so it returns exactly one control in both the broken and the fixed tree.
*/
describe('app-shell ActionParamDialog — the boolean branch names its control ONCE (objectui#3962)', () => {
const boolParam = def({ name: 'confirmed', label: 'Confirm This', type: 'boolean' });

it('gives the boolean control the host id and exactly one label element', async () => {
openDialog([boolParam]);

const checkbox = await screen.findByRole('checkbox');
// Explicit, not inherited: the id the dialog's own `<Label htmlFor>` names.
expect(checkbox).toHaveAttribute('id', 'confirmed');

const labels = Array.from(document.querySelectorAll('label[for="confirmed"]'));
expect(labels).toHaveLength(1);
// …and the surviving one is the dialog's VISIBLE label, not the widget's
// sr-only copy — a checkbox row whose only name is screen-reader-only
// would pass a bare count while showing the user nothing.
expect(labels[0].className).not.toContain('sr-only');
expect(labels[0]).toHaveTextContent('Confirm This');
});

it('announces the label once, not twice concatenated', async () => {
openDialog([boolParam]);

const checkbox = await screen.findByRole('checkbox');
expect(checkbox).toHaveAccessibleName('Confirm This');
});

it('still names a required boolean param without folding in the asterisk', async () => {
// The `*` marker lives inside the visible label; suppressing the widget's
// duplicate must not drag the visual-only marker into the name (#3299).
openDialog([def({ name: 'confirmed', label: 'Confirm This', type: 'boolean', required: true })]);

const checkbox = await screen.findByRole('checkbox');
expect(checkbox).toHaveAccessibleName('Confirm This');
expect(checkbox).toHaveAttribute('aria-required', 'true');
});

it('leaves the generic branch exactly as it was — host id, one label, one name', async () => {
// The generic branch already passed `id={param.name}`; this is the
// unchanged-direction half of the pin, so a future "simplification" that
// moves id ownership back into the widgets fails here as well.
openDialog([def({ name: 'note', label: 'Note This', type: 'text' })]);

const input = await screen.findByLabelText('Note This');
expect(input).toHaveAttribute('id', 'note');
expect(document.querySelectorAll('label[for="note"]')).toHaveLength(1);
expect(input).toHaveAccessibleName('Note This');
});
});
20 changes: 20 additions & 0 deletions packages/app-shell/src/views/ActionParamDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ describe('ActionParamDialog — shared field-widget rendering (ADR-0059)', () =>
await waitFor(() => expect(resolve).toHaveBeenCalledWith({ force: true }));
});

it('toggles the boolean param by clicking its visible label (objectui#3962)', async () => {
// The boolean branch renders `<Label htmlFor={param.name}>` beside the
// control and now hands the widget that same id explicitly, so the row's
// normal affordance — click the text — has to work off the host's own
// output rather than off `BooleanField`'s id fallback chain. Pinned as
// BEHAVIOR here; the naming half (one label, un-doubled accessible name)
// is pinned in ActionParamDialog.ariaRequired.test.tsx.
const resolve = openDialog([def({ name: 'force', label: 'Force It', type: 'boolean' })]);
const checkbox = await screen.findByRole('checkbox');
expect(checkbox).toHaveAttribute('aria-checked', 'false');

const label = document.querySelector('label[for="force"]') as HTMLLabelElement;
expect(label).not.toBeNull();
fireEvent.click(label);

await waitFor(() => expect(checkbox).toHaveAttribute('aria-checked', 'true'));
confirm();
await waitFor(() => expect(resolve).toHaveBeenCalledWith({ force: true }));
});

it('renders a select param through the shared SelectField (combobox trigger)', async () => {
openDialog([
def({
Expand Down
15 changes: 15 additions & 0 deletions packages/app-shell/src/views/ActionParamDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,21 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp
<div className="flex items-start gap-2">
<Suspense fallback={<div className="size-4 mt-0.5 animate-pulse rounded-sm bg-muted" aria-hidden="true" />}>
<Widget
// The HOST owns the control id (objectui#3962), exactly
// as the generic branch below does. Omitting it made
// this branch's `<Label htmlFor>` association IMPLICIT:
// it only resolved because `BooleanField`'s id fallback
// chain reaches `config.name`, which `paramToField`
// seeds from `param.name` — a host living off another
// package's fallback. Worse, a widget that receives no
// host id cannot know the host already rendered a label,
// so it emitted its own `sr-only` copy too, and two
// label elements referencing one control CONCATENATE
// into the accessible name (accname §2D): the checkbox
// announced "Confirm This Confirm This". Passing the id
// makes the association explicit and suppresses the
// duplicate (PR #3959's `emitOwnLabel = !hostId`).
id={param.name}
value={values[param.name] === true}
onChange={(checked: unknown) => updateValue(param.name, checked === true)}
field={field}
Expand Down
Loading