|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * The BUILT-IN `select` branch hands the host's control props to the control it |
| 11 | + * actually renders — objectui#3976. |
| 12 | + * |
| 13 | + * Radix's `Select.Root` renders no DOM element of its own, so every prop it does |
| 14 | + * not recognise is silently DROPPED instead of reaching an element. The branch |
| 15 | + * used to spread its whole DOM pass-through onto that Root, which is where |
| 16 | + * `<FormControl>`'s Slot puts the field's `id` / `aria-describedby` / |
| 17 | + * `aria-invalid` and the call site puts `aria-required`. Measured before the |
| 18 | + * fix, for a plain `{ name: 'status', label: 'Status', type: 'select' }`: |
| 19 | + * |
| 20 | + * label for="_r_1_-form-item" -> no element carries that id |
| 21 | + * getByLabelText(/status/i) -> 0 matches |
| 22 | + * trigger button -> no id, no aria-describedby, |
| 23 | + * no aria-invalid, no aria-required |
| 24 | + * |
| 25 | + * i.e. the visible "Status" label pointed at nothing (clicking it did nothing), |
| 26 | + * the field had no accessible name, and neither the validation message nor the |
| 27 | + * required state was announced. |
| 28 | + * |
| 29 | + * This is the same mechanism objectui#3306 fixed on the WIDGET side, where |
| 30 | + * `SelectField` now routes its pass-through to `SelectTrigger`. The two `select` |
| 31 | + * paths diverge because `BUILTIN_FIELD_TYPES` contains `'select'`: a bare |
| 32 | + * `type: 'select'` renders the built-in branch and never consults the registry, |
| 33 | + * while an object-driven `field:select` (`mapFieldTypeToFormType`) resolves to |
| 34 | + * the widget. Only the built-in half was broken, which is why the fork itself is |
| 35 | + * pinned below — both halves have to keep working independently, and a "fix" |
| 36 | + * that quietly rerouted bare `select` to the registry would change which |
| 37 | + * component renders for every hand-written form schema in existence. |
| 38 | + * |
| 39 | + * Reverse verification (run before writing the fix, direction predicted first): |
| 40 | + * restoring the spread onto `<Select>` turns the built-in describe RED — the |
| 41 | + * label-resolution, accessible-name, aria-required, aria-invalid and |
| 42 | + * aria-describedby cases all fail — while the registry describe stays GREEN, |
| 43 | + * because that path never went through Root. |
| 44 | + */ |
| 45 | + |
| 46 | +import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'; |
| 47 | +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; |
| 48 | +import { ComponentRegistry } from '@object-ui/core'; |
| 49 | +// Module scope, not `beforeAll` — the cold transform must not be billed to |
| 50 | +// `hookTimeout`. See object-ui/no-dynamic-import-in-test-hook (objectui#3010). |
| 51 | +import '../../../renderers'; |
| 52 | + |
| 53 | +const OPTIONS = [ |
| 54 | + { label: 'Open', value: 'open' }, |
| 55 | + { label: 'Closed', value: 'closed' }, |
| 56 | +]; |
| 57 | + |
| 58 | +/** |
| 59 | + * Stands in for a registered option widget (`@object-ui/components` tests never |
| 60 | + * load `@object-ui/fields`) and mirrors the one mechanism the registry path |
| 61 | + * relies on: spread the leftover props onto the control it renders. Registered |
| 62 | + * under the REAL `field:select` key so the fork below is measured, not assumed — |
| 63 | + * the issue's finding was that the built-in branch wins even when that key |
| 64 | + * resolves. |
| 65 | + */ |
| 66 | +function SelectWidgetProbe(props: any) { |
| 67 | + const { name, value: _value, onChange: _onChange, field: _field, error: _error, options: _options, ...rest } = props; |
| 68 | + return ( |
| 69 | + <button type="button" role="combobox" data-testid={`probe-${name}`} {...rest} /> |
| 70 | + ); |
| 71 | +} |
| 72 | + |
| 73 | +beforeAll(() => { |
| 74 | + ComponentRegistry.register('select', SelectWidgetProbe, { namespace: 'field' }); |
| 75 | +}, 30000); |
| 76 | + |
| 77 | +afterEach(() => { |
| 78 | + cleanup(); |
| 79 | + vi.restoreAllMocks(); |
| 80 | +}); |
| 81 | + |
| 82 | +function renderForm(fields: any[], defaultValues: Record<string, unknown> = {}) { |
| 83 | + const Form = ComponentRegistry.get('form')!; |
| 84 | + return render( |
| 85 | + <Form |
| 86 | + schema={{ |
| 87 | + type: 'form', |
| 88 | + mode: 'create', |
| 89 | + showSubmit: true, |
| 90 | + showCancel: false, |
| 91 | + submitLabel: 'Create', |
| 92 | + defaultValues, |
| 93 | + fields, |
| 94 | + onSubmit: () => {}, |
| 95 | + }} |
| 96 | + />, |
| 97 | + ); |
| 98 | +} |
| 99 | + |
| 100 | +const STATUS_FIELD = { name: 'status', label: 'Status', type: 'select', options: OPTIONS }; |
| 101 | + |
| 102 | +/** The trigger button, by role — fails loudly if it stops being a combobox. */ |
| 103 | +function triggerOf(name: string): Element { |
| 104 | + const el = document.querySelector(`[data-field="${name}"] [role="combobox"]`); |
| 105 | + if (!el) throw new Error(`no select trigger rendered for field "${name}"`); |
| 106 | + return el; |
| 107 | +} |
| 108 | + |
| 109 | +/** Every `<label for=…>` in the document, paired with its resolved target. */ |
| 110 | +function labelTargets(): Array<{ text: string; forId: string; resolves: boolean }> { |
| 111 | + return Array.from(document.querySelectorAll('label')) |
| 112 | + .map((el) => ({ |
| 113 | + text: (el.textContent ?? '').trim(), |
| 114 | + forId: el.getAttribute('for') ?? '', |
| 115 | + resolves: !!el.getAttribute('for') && !!document.getElementById(el.getAttribute('for')!), |
| 116 | + })) |
| 117 | + .filter((l) => l.forId !== ''); |
| 118 | +} |
| 119 | + |
| 120 | +const submit = () => fireEvent.click(screen.getByRole('button', { name: /create/i })); |
| 121 | + |
| 122 | +describe('built-in select — the host id reaches the trigger (objectui#3976)', () => { |
| 123 | + it('emits no label pointing at an id nothing carries', () => { |
| 124 | + renderForm([STATUS_FIELD], { status: undefined }); |
| 125 | + |
| 126 | + const labels = labelTargets(); |
| 127 | + expect(labels.length).toBeGreaterThan(0); |
| 128 | + // The defect verbatim: `for="_r_N_-form-item"` with no owner in the document. |
| 129 | + expect(labels.filter((l) => !l.resolves)).toEqual([]); |
| 130 | + }); |
| 131 | + |
| 132 | + it('resolves the visible label to the trigger, not to a wrapper', () => { |
| 133 | + renderForm([STATUS_FIELD], { status: undefined }); |
| 134 | + |
| 135 | + // `getAllByLabelText` walks `for`→id, so it guards the association itself: |
| 136 | + // before the fix it returned 0 matches (the id had no owner at all). |
| 137 | + const labelled = screen.getAllByLabelText('Status'); |
| 138 | + expect(labelled).toHaveLength(1); |
| 139 | + expect(labelled[0]).toBe(triggerOf('status')); |
| 140 | + // …and the element carrying the id is the focusable control, not the row. |
| 141 | + expect(labelled[0].tagName).toBe('BUTTON'); |
| 142 | + }); |
| 143 | + |
| 144 | + it('gives the combobox the label as its accessible name', () => { |
| 145 | + renderForm([STATUS_FIELD], { status: undefined }); |
| 146 | + |
| 147 | + // The user-visible consequence, asserted on the computed name rather than on |
| 148 | + // markup: pre-fix the control was reachable by role but ANONYMOUS, so a |
| 149 | + // screen reader announced "combobox" with no field name. |
| 150 | + const combobox = screen.getByRole('combobox', { name: 'Status' }); |
| 151 | + expect(combobox).toHaveAccessibleName('Status'); |
| 152 | + expect(combobox).toBe(triggerOf('status')); |
| 153 | + }); |
| 154 | + |
| 155 | + it('keeps `for` as the association channel — no second naming channel', () => { |
| 156 | + // The built-in `select` renders a labelable `<button>`, so `resolveFieldLabelling` |
| 157 | + // correctly classifies it as `'control'` (objectui#3961/#3978): plain `for`, |
| 158 | + // and NO `aria-labelledby`. Two channels for one label is the failure the |
| 159 | + // group-labelling work exists to avoid, and this fix must not introduce it. |
| 160 | + renderForm([STATUS_FIELD], { status: undefined }); |
| 161 | + |
| 162 | + expect(screen.getByText('Status').getAttribute('for')).toBe( |
| 163 | + triggerOf('status').getAttribute('id'), |
| 164 | + ); |
| 165 | + expect(triggerOf('status')).not.toHaveAttribute('aria-labelledby'); |
| 166 | + }); |
| 167 | + |
| 168 | + it('carries aria-required on the trigger, and nothing when optional', () => { |
| 169 | + renderForm([{ ...STATUS_FIELD, required: true }], { status: undefined }); |
| 170 | + expect(triggerOf('status')).toHaveAttribute('aria-required', 'true'); |
| 171 | + |
| 172 | + cleanup(); |
| 173 | + renderForm([STATUS_FIELD], { status: undefined }); |
| 174 | + expect(triggerOf('status')).not.toHaveAttribute('aria-required'); |
| 175 | + }); |
| 176 | + |
| 177 | + it('announces invalid on the trigger only AFTER validation fails', async () => { |
| 178 | + renderForm([{ ...STATUS_FIELD, required: true }], { status: undefined }); |
| 179 | + |
| 180 | + // The explicit "false" half is load-bearing (#3222/#3306): a valid field |
| 181 | + // SAYS it is valid rather than staying mute. |
| 182 | + expect(triggerOf('status')).toHaveAttribute('aria-invalid', 'false'); |
| 183 | + |
| 184 | + submit(); |
| 185 | + |
| 186 | + // Prove the failure actually rendered before reading aria off it. |
| 187 | + await waitFor(() => expect(screen.getAllByText('Status is required')).toHaveLength(1)); |
| 188 | + expect(triggerOf('status')).toHaveAttribute('aria-invalid', 'true'); |
| 189 | + }); |
| 190 | + |
| 191 | + it('links aria-describedby to the rendered error message', async () => { |
| 192 | + renderForm([{ ...STATUS_FIELD, required: true }], { status: undefined }); |
| 193 | + |
| 194 | + submit(); |
| 195 | + |
| 196 | + const message = await screen.findByText('Status is required'); |
| 197 | + // The association is only real if the id chain closes: the trigger must |
| 198 | + // point at the exact element carrying the message text. |
| 199 | + expect(message.id).not.toBe(''); |
| 200 | + const describedBy = triggerOf('status').getAttribute('aria-describedby') ?? ''; |
| 201 | + expect(describedBy.split(/\s+/)).toContain(message.id); |
| 202 | + }); |
| 203 | + |
| 204 | + it('leaves `name` on Root, where the submitted hidden select is', () => { |
| 205 | + // The whitelist half of the fix, and a regression fence against "move |
| 206 | + // everything to the trigger": `name` is the ONE key Root genuinely consumes |
| 207 | + // — it forwards it to the hidden native `<select>` that takes part in form |
| 208 | + // submission. On the trigger it would sit uselessly on a non-submitter |
| 209 | + // `<button>`. |
| 210 | + renderForm([STATUS_FIELD], { status: undefined }); |
| 211 | + |
| 212 | + const hidden = document.querySelector('[data-field="status"] select[aria-hidden="true"]'); |
| 213 | + expect(hidden).toHaveAttribute('name', 'status'); |
| 214 | + expect(triggerOf('status')).not.toHaveAttribute('name'); |
| 215 | + }); |
| 216 | + |
| 217 | + it('still keeps `disabled` a single-authority prop on Root', () => { |
| 218 | + // Root disables the trigger, the items and the hidden select together, so |
| 219 | + // the raw prop must not gain a second author on the trigger. |
| 220 | + renderForm([{ ...STATUS_FIELD, disabled: true }], { status: undefined }); |
| 221 | + |
| 222 | + expect(triggerOf('status')).toBeDisabled(); |
| 223 | + }); |
| 224 | + |
| 225 | + it('two selects in one form each name their own trigger', () => { |
| 226 | + renderForm( |
| 227 | + [STATUS_FIELD, { name: 'stage', label: 'Stage', type: 'select', options: OPTIONS }], |
| 228 | + { status: undefined, stage: undefined }, |
| 229 | + ); |
| 230 | + |
| 231 | + expect(labelTargets().filter((l) => !l.resolves)).toEqual([]); |
| 232 | + // A shared/colliding id would make both labels resolve to the same control. |
| 233 | + expect(screen.getByRole('combobox', { name: 'Status' })).toBe(triggerOf('status')); |
| 234 | + expect(screen.getByRole('combobox', { name: 'Stage' })).toBe(triggerOf('stage')); |
| 235 | + }); |
| 236 | +}); |
| 237 | + |
| 238 | +describe('the two select paths stay separate (objectui#3976 / #3231)', () => { |
| 239 | + it('a bare `type: select` renders the BUILT-IN branch even though field:select resolves', () => { |
| 240 | + // Measured, not inferred: `renderFieldComponent` skips the registry entirely |
| 241 | + // for a `BUILTIN_FIELD_TYPES` member, so the probe registered above under |
| 242 | + // the real `field:select` key does NOT win. That is why #3976 had to be |
| 243 | + // fixed in the built-in branch — rerouting bare `select` to the registry is |
| 244 | + // a design change (it swaps the rendered component for every hand-written |
| 245 | + // form schema), not a bug fix. |
| 246 | + expect(ComponentRegistry.get('field:select')).toBe(SelectWidgetProbe); |
| 247 | + |
| 248 | + renderForm([STATUS_FIELD], { status: undefined }); |
| 249 | + |
| 250 | + expect(screen.queryByTestId('probe-status')).toBeNull(); |
| 251 | + // The Radix trigger + its hidden native select — the built-in branch's DOM. |
| 252 | + expect(triggerOf('status')).toHaveAttribute('aria-autocomplete', 'none'); |
| 253 | + expect(document.querySelector('[data-field="status"] select[aria-hidden="true"]')).not.toBeNull(); |
| 254 | + }); |
| 255 | + |
| 256 | + it('a registered `field:select` widget still receives the host props (positive control)', () => { |
| 257 | + // The path #3306 already fixed. It must be untouched by this change: the |
| 258 | + // widget gets the id / aria-* as PROPS (the Slot clones the element it |
| 259 | + // returns) and puts them on the control it renders. |
| 260 | + renderForm( |
| 261 | + [{ name: 'choice', label: 'Choice', type: 'field:select', required: true, options: OPTIONS }], |
| 262 | + { choice: undefined }, |
| 263 | + ); |
| 264 | + |
| 265 | + const probe = screen.getByTestId('probe-choice'); |
| 266 | + expect(labelTargets().filter((l) => !l.resolves)).toEqual([]); |
| 267 | + expect(screen.getByRole('combobox', { name: 'Choice' })).toBe(probe); |
| 268 | + expect(probe).toHaveAttribute('aria-required', 'true'); |
| 269 | + expect(probe).toHaveAttribute('aria-invalid', 'false'); |
| 270 | + expect(probe.getAttribute('id')).toMatch(/-form-item$/); |
| 271 | + }); |
| 272 | +}); |
0 commit comments