diff --git a/.changeset/boolean-field-host-owns-control-id.md b/.changeset/boolean-field-host-owns-control-id.md new file mode 100644 index 0000000000..12e78072a1 --- /dev/null +++ b/.changeset/boolean-field-host-owns-control-id.md @@ -0,0 +1,9 @@ +--- +'@object-ui/fields': patch +--- + +fix(fields): `BooleanField` uses the control id its host hands down, so a boolean field's visible label is really associated with the switch + +A `boolean` / `checkbox` field inside a form emitted **two** labels with the same text, and the visible one pointed at nothing. `` (a Radix `Slot`) hands the control the id its `` already references, and the widget replaced it with the field name — so `label for="_r_3_-form-item"` had no target while the switch carried `id="notifications"`. Clicking the visible label, the normal affordance for a switch/checkbox row, toggled nothing on every generated form in every app; the accessible name survived only through the widget's own `sr-only` label. + +The widget now honours the id it was handed (`id` is a declared key of the widget contract, forwarded by `toDomProps`) and stops emitting its own label when a host supplied one. Standalone rendering is unchanged: with no host id the id still falls back to the field name and then to `useId()`, and the `sr-only` label — the only accessible name the inline grid editor and the console's action-param dialog have — is still emitted. diff --git a/packages/fields/src/__tests__/boolean-label-association-e2e.test.tsx b/packages/fields/src/__tests__/boolean-label-association-e2e.test.tsx new file mode 100644 index 0000000000..683dedd4ec --- /dev/null +++ b/packages/fields/src/__tests__/boolean-label-association-e2e.test.tsx @@ -0,0 +1,298 @@ +/** + * 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. + */ + +/** + * End-to-end: a boolean field's VISIBLE label is associated with the control + * the widget renders (objectui#3952). + * + * The defect: `` is a Radix `Slot` that hands its child the id + * `` already points at (`_r_N_-form-item`), and + * `BooleanField` REPLACED it with the field name. So the form's visible label + * referenced an id no element carried — clicking it toggled nothing — while the + * widget emitted a SECOND, `sr-only` label of its own carrying the same text. + * The accessible name survived only by that accident. + * + * Two scenarios, pinned separately, because the widget has two hosts with + * opposite needs: + * + * - **hosted** (the form renderer, `ObjectForm`, any ``): the host + * owns the id AND the label. The widget must use the id it was handed and + * must NOT emit a label of its own — that is the duplicate half of the bug. + * - **standalone** (`FieldEditWidget`'s inline grid editor, a bare SDUI node): + * nobody hands an id and nobody renders a label, so the widget's own + * `sr-only` label is the control's ONLY accessible name and must stay. + * + * The widgets are registered raw rather than through `registerAllFields()`, + * which wraps every loader in `React.lazy`: an unbounded module load inside a + * bounded `findBy`/`waitFor` window is the repo's known flake generator + * (AGENTS.md 测试纪律, objectui#3010). Same component, no Suspense boundary. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { ComponentRegistry } from '@object-ui/core'; +// Module scope: pulls in the form renderer's registration side effect. +import '@object-ui/components'; + +import { BooleanField } from '../widgets/BooleanField'; + +beforeAll(() => { + ComponentRegistry.register('boolean', BooleanField as any, { + namespace: 'field', + skipFallback: true, + }); +}, 30000); + +beforeEach(() => { + if (!(Element.prototype as any).scrollIntoView) { + (Element.prototype as any).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +/** The real form renderer hosting one field — the #3952 reproduction. */ +function renderForm(field: any, defaultValue: unknown) { + const Form = ComponentRegistry.get('form')!; + return render( +
{}, + }} + />, + ); +} + +/** Every `