From 190183f38298ff823a6e5c1d4871dd2a8bb39674 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Thu, 8 Oct 2026 20:57:42 -0400 Subject: [PATCH 01/22] fix(bulma-ui): name Autocomplete's suggestion list from a surrounding Field's label Inside a labelled Field the combobox took the Field's label as its name, but the open list still fell back to "Suggestions", so two Autocompletes in one form announced lists with the same name. The list now takes its name from whichever label names the combobox, its own or the Field's, pointing aria-labelledby at the Field's label through the id the Field gives it while it wires that label itself. "Suggestions" is left for a list whose combobox no label names, which now includes an own label opted out of the association, and for a Field label wired by hand, which carries no id to point at. The tests run the component's own label and a Field's label through the same cases, including that nothing points at a missing id as the list opens and closes. Refs #998 --- bulma-ui/src/form/Autocomplete.tsx | 27 +- .../src/form/__tests__/Autocomplete.test.tsx | 232 +++++++++++++++--- 2 files changed, 219 insertions(+), 40 deletions(-) diff --git a/bulma-ui/src/form/Autocomplete.tsx b/bulma-ui/src/form/Autocomplete.tsx index 71e070e15..1fa913cea 100644 --- a/bulma-ui/src/form/Autocomplete.tsx +++ b/bulma-ui/src/form/Autocomplete.tsx @@ -13,7 +13,7 @@ import { } from '../helpers/classNames'; import { useConfig } from '../helpers/Config'; import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; -import { useInsideField } from './FormContext'; +import { useFieldLabelElementId, useInsideField } from './FormContext'; import { Field } from './Field'; import { FormFieldProps } from './fieldProps'; import { useAutoLabelId } from './useAutoLabelId'; @@ -42,9 +42,9 @@ export interface AutocompleteProps Omit, 'onSelect' | 'onInput'>, Omit, FormFieldProps { - /** Field label. Automatically associated with the text input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Dropped inside an outer `Field`, whose own label associates instead when that `Field` generates a target id (not `grouped`/`hasAddons`, no explicit `labelProps.htmlFor`). */ + /** Field label. Automatically associated with the text input via `htmlFor` — uses your `id` when provided, otherwise a generated one. While it names the input it also names the open suggestion list, through `aria-labelledby`. Dropped inside an outer `Field`, whose own label associates and names the list instead when that `Field` generates a target id (not `grouped`/`hasAddons`, no explicit `labelProps.htmlFor`). When no label names the input, or the label naming it is an outer `Field`'s wired by hand, the list is named "Suggestions". */ label?: React.ReactNode; - /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). */ + /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (the input then gets no generated id). While the label names the input, the suggestion list's `aria-labelledby` points at it, through an `id` here when you give one and a generated one otherwise. */ labelProps?: React.LabelHTMLAttributes & { [key: string]: unknown; }; @@ -188,12 +188,16 @@ export const Autocomplete = forwardRef( ref ) => { const insideField = useInsideField(); - const { controlId, fieldLabelProps } = useAutoLabelId({ + const { controlId, fieldLabelProps, labelled } = useAutoLabelId({ label, id, labelProps, rendersLabel: !insideField, }); + // The id of a surrounding Field's own label, set only while that Field + // wires its label itself, which is when the label renders with this id. + // Undefined under a label wired by hand, which carries no id of ours. + const fieldLabelElementId = useFieldLabelElementId(); const { bulmaHelperClasses, rest } = useBulmaClasses(props); const { classPrefix } = useConfig(); const containerRef = useRef(null); @@ -455,13 +459,16 @@ export const Autocomplete = forwardRef( clearable && !!inputValue && !disabled && !loading && !controlLoading; // The combobox controls its listbox and points at the highlighted option. - // The listbox takes its name from the label this renders, or a fallback - // when it renders none. + // The listbox takes its name from the label that names the combobox, the + // one this renders or a surrounding Field's, so the two say the same + // thing. When no label names the combobox, or the one that does has no + // id to point at, the list keeps a fallback name. const listOpen = isActive && (filteredData.length > 0 || !!empty); - const listLabelId = - !insideField && label - ? ((fieldLabelProps?.id as string | undefined) ?? `${listboxId}-label`) - : undefined; + const listLabelId = !labelled + ? undefined + : insideField + ? fieldLabelElementId + : ((fieldLabelProps?.id as string | undefined) ?? `${listboxId}-label`); const activeOptionId = listOpen && highlightedIndex >= 0 && diff --git a/bulma-ui/src/form/__tests__/Autocomplete.test.tsx b/bulma-ui/src/form/__tests__/Autocomplete.test.tsx index 1f51de1c5..74e3ae7e4 100644 --- a/bulma-ui/src/form/__tests__/Autocomplete.test.tsx +++ b/bulma-ui/src/form/__tests__/Autocomplete.test.tsx @@ -13,6 +13,28 @@ const countries = [ { value: 'uk', label: 'United Kingdom' }, ]; +/** + * Each id reference under `root` that names no element in the document, as + * `attribute=id`, so a reference left pointing at nothing shows up by name. + */ +const danglingIdRefs = (root: HTMLElement): string[] => { + const attributes = [ + 'aria-labelledby', + 'aria-describedby', + 'aria-controls', + 'aria-activedescendant', + 'for', + ]; + return Array.from(root.querySelectorAll('*')).flatMap(el => + attributes.flatMap(attribute => + (el.getAttribute(attribute) ?? '') + .split(/\s+/) + .filter(id => id && !document.getElementById(id)) + .map(id => `${attribute}=${id}`) + ) + ); +}; + describe('Autocomplete', () => { describe('Rendering', () => { it('renders an input element', () => { @@ -660,45 +682,195 @@ describe('Autocomplete', () => { expect(input).not.toHaveAttribute('aria-activedescendant'); }); - it('names the listbox by its label', () => { - render(); - fireEvent.change(screen.getByRole('combobox'), { - target: { value: 'a' }, - }); - expect(screen.getByRole('listbox')).toHaveAccessibleName('Fruit'); - }); + // The label that names the input names the list too, whichever renders + // it: the Autocomplete's own `label` or a Field's label around it, so + // both go through the same cases. Each builds an Autocomplete labelled + // `name`, passing `labelProps` to whichever renders the label. + type LabelledUi = ( + name: string, + labelProps?: { id: string } + ) => React.ReactElement; + const labelSources: Array<[string, LabelledUi]> = [ + [ + 'its own label prop', + (name, labelProps) => ( + + ), + ], + [ + 'its own label wired by hand to its own id', + (name, labelProps) => ( + + ), + ], + [ + "a surrounding Field's label", + (name, labelProps) => ( + + + + ), + ], + [ + "a Field's label over its own dropped one", + (name, labelProps) => ( + + + + ), + ], + [ + "a Field's label through a Control", + (name, labelProps) => ( + + + + + + ), + ], + ]; - it('names the listbox through a labelProps id of your own', () => { - render( - + const openList = (combobox: HTMLElement) => + fireEvent.change(combobox, { target: { value: 'a' } }); + + it.each(labelSources)('names the listbox by %s', (_source, ui) => { + render(ui('Fruit')); + openList(screen.getByRole('combobox', { name: 'Fruit' })); + const listbox = screen.getByRole('listbox'); + expect(listbox).toHaveAccessibleName('Fruit'); + expect(listbox).not.toHaveAttribute('aria-label'); + // The id it points at is the rendered label's own. + const label = document.getElementById( + listbox.getAttribute('aria-labelledby') ?? '' ); - fireEvent.change(screen.getByRole('combobox'), { - target: { value: 'a' }, - }); - expect(screen.getByRole('listbox')).toHaveAttribute( - 'aria-labelledby', - 'fruit-label' + expect(label?.tagName).toBe('LABEL'); + expect(label).toHaveTextContent('Fruit'); + }); + + it.each(labelSources)( + 'names the listbox through a labelProps id of your own on %s', + (_source, ui) => { + render(ui('Fruit', { id: 'fruit-label' })); + openList(screen.getByRole('combobox', { name: 'Fruit' })); + const listbox = screen.getByRole('listbox'); + expect(listbox).toHaveAttribute('aria-labelledby', 'fruit-label'); + expect(listbox).toHaveAccessibleName('Fruit'); + } + ); + + it.each(labelSources)('tells two lists apart by %s', (_source, ui) => { + render( + <> + {ui('Country')} + {ui('City')} + ); - expect(screen.getByRole('listbox')).toHaveAccessibleName('Fruit'); - }); + const [country, city] = screen.getAllByRole('combobox'); + openList(country); + openList(city); + expect( + screen.getByRole('listbox', { name: 'Country' }) + ).toBeInTheDocument(); + expect(screen.getByRole('listbox', { name: 'City' })).toBeInTheDocument(); + }); + + it.each(labelSources)( + 'points at no missing id as the list opens and closes, labelled by %s', + (_source, ui) => { + const { container } = render(ui('Fruit')); + const combobox = screen.getByRole('combobox'); + expect(danglingIdRefs(container)).toEqual([]); + openList(combobox); + fireEvent.keyDown(combobox, { key: 'ArrowDown' }); + expect(screen.getByRole('listbox')).toHaveAttribute('aria-labelledby'); + expect(combobox).toHaveAttribute('aria-activedescendant'); + expect(danglingIdRefs(container)).toEqual([]); + fireEvent.keyDown(combobox, { key: 'Escape' }); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + expect(danglingIdRefs(container)).toEqual([]); + } + ); it.each([ ['with no label', ], [ - 'inside an outer Field', - - + 'under its own label opted out of the association', + , + ], + [ + 'under its own label wired to another control', + , + ], + [ + "with an id of its own that a Field's label misses", + + , ], - ])('falls back to a name for the listbox %s', (_name, ui) => { - render(ui); - fireEvent.change(screen.getByRole('combobox'), { - target: { value: 'a' }, - }); + [ + 'in a grouped Field', + + + , + ], + [ + 'in a Field with addons', + + + , + ], + [ + 'in a Field whose label opts out of the association', + + + , + ], + [ + 'in an unlabelled Field inside a labelled one', + + + + + , + ], + ])( + 'keeps the fallback name when no label names the input, %s', + (_name, ui) => { + render(ui); + const combobox = screen.getByRole('combobox'); + expect(combobox).toHaveAccessibleName(''); + openList(combobox); + const listbox = screen.getByRole('listbox'); + expect(listbox).toHaveAccessibleName('Suggestions'); + expect(listbox).not.toHaveAttribute('aria-labelledby'); + } + ); + + it('keeps the fallback name under a Field label wired by hand, which has no id to point at', () => { + const { container } = render( + + + + ); + const combobox = screen.getByRole('combobox', { name: 'Fruit' }); + expect(container.querySelector('label')).not.toHaveAttribute('id'); + openList(combobox); const listbox = screen.getByRole('listbox'); expect(listbox).toHaveAccessibleName('Suggestions'); expect(listbox).not.toHaveAttribute('aria-labelledby'); From 1a7b0a869d6422e27ac8dfb6b0902f023db05891 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Thu, 8 Oct 2026 20:58:08 -0400 Subject: [PATCH 02/22] docs: say the label that names an Autocomplete names its suggestion list The Accessibility section said the list fell back to "Suggestions" inside any outer Field. It now takes the name of whichever label names the input, the component's own or a Field's, and the fallback is left for an input no label names. The horizontal examples wire their label by hand, which gives the list no id to point at, so the page says the list keeps its fallback name there. Refs #998 --- docs/docs/api/form/autocomplete.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/docs/api/form/autocomplete.md b/docs/docs/api/form/autocomplete.md index b9d2cdb6d..90d0dddfc 100644 --- a/docs/docs/api/form/autocomplete.md +++ b/docs/docs/api/form/autocomplete.md @@ -303,7 +303,7 @@ The simplest usage — the component automatically renders its own Field wrapper When you need manual control over the Field layout (e.g., horizontal forms), wrap the component in `Field`. The component detects it's inside a Field and skips rendering its own. -A labeled `Field` that holds the Autocomplete directly names its input with no extra wiring. In a horizontal form the label sits on the outer `Field` and the Autocomplete in an inner one, which starts its own scope, so these examples wire the label by hand with `labelProps={{ htmlFor }}` and a matching `id`. +A labeled `Field` that holds the Autocomplete directly names its input and its open list with no extra wiring. In a horizontal form the label sits on the outer `Field` and the Autocomplete in an inner one, which starts its own scope, so these examples wire the label by hand with `labelProps={{ htmlFor }}` and a matching `id`. That names the input, while the list keeps its fallback name, "Suggestions". ```tsx live function example() { @@ -413,7 +413,7 @@ function AutocompleteFormDemo() { - Uses `role="combobox"` with `aria-expanded`, which is true only while the list is on screen - Has `aria-haspopup="listbox"` and `aria-autocomplete="list"`, and, while the list is on screen, `aria-controls` pointing at it - Focus stays in the input, which points `aria-activedescendant` at the highlighted option as the arrow keys move it -- The list is a `role="listbox"` named by the `label` prop's label. Inside an outer `Field`, or with no `label`, it falls back to `aria-label="Suggestions"` +- The list is a `role="listbox"` named through `aria-labelledby` by the label that names the input: the `label` prop's label or, inside a labeled `Field` that wires its label itself, that `Field`'s label. It falls back to `aria-label="Suggestions"` when no label names the input, and when the one that does is an outer `Field`'s label wired by hand with `labelProps.htmlFor`, which is how a horizontal form names an Autocomplete in an inner `Field` - Dropdown items use `role="option"` with `aria-selected` - Disabled items have `aria-disabled` - The `clearable` clear button is a native button named "Clear": `Tab` from the input reaches it, and `Enter` or `Space` clears the input and puts focus back in it From d9c82dd78262e51c8fbe020c2616ea5872c4e83b Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Thu, 8 Oct 2026 20:58:54 -0400 Subject: [PATCH 03/22] chore: regenerate The Autocomplete API page's props table and its MCP index entry, from the label and labelProps TSDoc and the page's Accessibility section. Refs #998 --- bestax-mcp/data/components/Autocomplete.json | 6 +- docs/docs/api/form/autocomplete.md | 84 ++++++++++---------- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/bestax-mcp/data/components/Autocomplete.json b/bestax-mcp/data/components/Autocomplete.json index 19df1d636..91352c9ae 100644 --- a/bestax-mcp/data/components/Autocomplete.json +++ b/bestax-mcp/data/components/Autocomplete.json @@ -54,7 +54,7 @@ "code": "function example() {\n return (\n \n \n \n \n \n \n \n \n \n );\n}" } ], - "accessibility": "- The `label` prop is automatically associated with the inner text input (`htmlFor` plus a generated `id`, or your own `id` if you pass one), so clicking the label focuses the input and assistive technology announces it. Inside a labeled `Field`, that `Field`'s label names the input the same way.\n- A user-supplied `id` is applied to the inner input (the labellable control), not the wrapper div.\n- Uses `role=\"combobox\"` with `aria-expanded`, which is true only while the list is on screen\n- Has `aria-haspopup=\"listbox\"` and `aria-autocomplete=\"list\"`, and, while the list is on screen, `aria-controls` pointing at it\n- Focus stays in the input, which points `aria-activedescendant` at the highlighted option as the arrow keys move it\n- The list is a `role=\"listbox\"` named by the `label` prop's label. Inside an outer `Field`, or with no `label`, it falls back to `aria-label=\"Suggestions\"`\n- Dropdown items use `role=\"option\"` with `aria-selected`\n- Disabled items have `aria-disabled`\n- The `clearable` clear button is a native button named \"Clear\": `Tab` from the input reaches it, and `Enter` or `Space` clears the input and puts focus back in it\n- Full keyboard navigation support", + "accessibility": "- The `label` prop is automatically associated with the inner text input (`htmlFor` plus a generated `id`, or your own `id` if you pass one), so clicking the label focuses the input and assistive technology announces it. Inside a labeled `Field`, that `Field`'s label names the input the same way.\n- A user-supplied `id` is applied to the inner input (the labellable control), not the wrapper div.\n- Uses `role=\"combobox\"` with `aria-expanded`, which is true only while the list is on screen\n- Has `aria-haspopup=\"listbox\"` and `aria-autocomplete=\"list\"`, and, while the list is on screen, `aria-controls` pointing at it\n- Focus stays in the input, which points `aria-activedescendant` at the highlighted option as the arrow keys move it\n- The list is a `role=\"listbox\"` named through `aria-labelledby` by the label that names the input: the `label` prop's label or, inside a labeled `Field` that wires its label itself, that `Field`'s label. It falls back to `aria-label=\"Suggestions\"` when no label names the input, and when the one that does is an outer `Field`'s label wired by hand with `labelProps.htmlFor`, which is how a horizontal form names an Autocomplete in an inner `Field`\n- Dropdown items use `role=\"option\"` with `aria-selected`\n- Disabled items have `aria-disabled`\n- The `clearable` clear button is a native button named \"Clear\": `Tab` from the input reaches it, and `Enter` or `Space` clears the input and puts focus back in it\n- Full keyboard navigation support", "related": ["Input", "Select", "Taginput"], "storybook": "https://bestax.io/storybook/?path=/story/form-autocomplete", "summary": "The `Autocomplete` component provides an input field with dropdown suggestions that filter based on user input.", @@ -71,7 +71,7 @@ "name": "label", "type": "React.ReactNode", "default": null, - "description": "Field label. Automatically associated with the text input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Dropped inside an outer `Field`, whose own label associates instead when that `Field` generates a target id (not `grouped`/`hasAddons`, no explicit `labelProps.htmlFor`).", + "description": "Field label. Automatically associated with the text input via `htmlFor` — uses your `id` when provided, otherwise a generated one. While it names the input it also names the open suggestion list, through `aria-labelledby`. Dropped inside an outer `Field`, whose own label associates and names the list instead when that `Field` generates a target id (not `grouped`/`hasAddons`, no explicit `labelProps.htmlFor`). When no label names the input, or the label naming it is an outer `Field`'s wired by hand, the list is named \"Suggestions\".", "required": false, "inherited": false, "deprecated": false @@ -80,7 +80,7 @@ "name": "labelProps", "type": "React.LabelHTMLAttributes & { [key: string]: unknown; }", "default": null, - "description": "Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then).", + "description": "Props for the label element. An explicit `htmlFor` here overrides the automatic association (the input then gets no generated id). While the label names the input, the suggestion list's `aria-labelledby` points at it, through an `id` here when you give one and a generated one otherwise.", "required": false, "inherited": false, "deprecated": false diff --git a/docs/docs/api/form/autocomplete.md b/docs/docs/api/form/autocomplete.md index 90d0dddfc..9ab9ed086 100644 --- a/docs/docs/api/form/autocomplete.md +++ b/docs/docs/api/form/autocomplete.md @@ -443,48 +443,48 @@ Use `keepFirst` combined with `Enter` to quickly select the first matching resul -| Prop | Type | Default | Description | -| ------------------------ | ------------------------------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `label` | `React.ReactNode` | — | Field label. Automatically associated with the text input via `htmlFor` — uses your `id` when provided, otherwise a generated one. Dropped inside an outer `Field`, whose own label associates instead when that `Field` generates a target id (not `grouped`/`hasAddons`, no explicit `labelProps.htmlFor`). | -| `labelProps` | `React.LabelHTMLAttributes & { [key: string]: unknown; }` | — | Props for the label element. An explicit `htmlFor` here overrides the automatic association (no id is generated then). | -| `id` | `string` | — | Applied to the inner text input (the labellable control), not the wrapper div. | -| `data` | `AutocompleteItem[]` \| `string[]` | `[]` | The options data to display (required). | -| `value` | `string` | — | The current input value (controlled). | -| `selected` | `AutocompleteItem` \| `string` \| `null` | — | The selected item (controlled). | -| `placeholder` | `string` | — | Placeholder text for the input. | -| `field` | `string` | `'label'` | Object property to use as the display field. | -| `clearable` | `boolean` | `false` | Whether to show a clear button while the input holds a value. It hides while the input is disabled, and gives way to a loading spinner at the same right edge: the one `loading` draws, or that of a `Control` with `isLoading` around the Autocomplete. | -| `openOnFocus` | `boolean` | `false` | Open dropdown when input is focused. | -| `keepFirst` | `boolean` | `false` | Keep first option highlighted. | -| `keepOpen` | `boolean` | `false` | Keep dropdown open after selection. | -| `selectOnClickOutside` | `boolean` | `false` | Select highlighted item on click outside. | -| `maxHeight` | `number` | `200` | Maximum dropdown height in pixels. | -| `dropdown` | `boolean` | `false` | Render as dropdown style. | -| `loading` | `boolean` | `false` | Show a loading spinner in the input. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded). | -| `disabled` | `boolean` | `false` | Whether the input is disabled. | -| `checkInfiniteScroll` | `boolean` | `false` | Enables infinite scroll detection in the dropdown. | -| `infiniteScrollDistance` | `number` | `50` | Distance in pixels from the bottom to trigger `onInfiniteScroll`. | -| `color` | `'primary'` \| `'link'` \| `'info'` \| `'success'` \| `'warning'` \| `'danger'` | — | Input color variant. | -| `size` | `'small'` \| `'medium'` \| `'large'` | — | Size variant. | -| `name` | `string` | — | Form field name. Forwarded to the inner ``. | -| `form` | `string` | — | Optional id of the form the input belongs to. | -| `required` | `boolean` | `false` | Marks the field as required for native HTML form validation. | -| `onInput` | `(value: string) => void` | — | Callback when input value changes. | -| `onSelect` | `(item: AutocompleteItem \| string \| null) => void` | — | Callback when item is selected. | -| `onActiveChange` | `(active: boolean) => void` | — | Callback when dropdown active state changes. | -| `onInfiniteScroll` | `() => void` | — | Callback when scrolled to bottom (infinite scroll). | -| `itemTemplate` | `(item: AutocompleteItem \| string) => React.ReactNode` | — | Custom render for items. | -| `header` | `React.ReactNode` | — | Custom header in dropdown. | -| `footer` | `React.ReactNode` | — | Custom footer in dropdown. | -| `empty` | `React.ReactNode` | — | Content to show when no results. | -| `labelSize` | `'small'` \| `'normal'` \| `'medium'` \| `'large'` | — | Size for the label (used in horizontal layouts). | -| `horizontal` | `boolean` | `false` | Horizontal field layout. | -| `message` | `React.ReactNode` | — | Help/validation message below the input. | -| `messageColor` | `'primary'` \| `'link'` \| `'info'` \| `'success'` \| `'warning'` \| `'danger'` | — | Bulma color for the message. | -| `fieldClassName` | `string` | — | Additional CSS classes for the Field wrapper. | -| `className` | `string` | — | Additional CSS classes. | -| `ref` | `React.Ref` | — | Ref forwarded to the input element. | -| `...` | All standard `
` attributes and Bulma helper props | — | See [Helper Props](../helpers/usebulmaclasses.md) | +| Prop | Type | Default | Description | +| ------------------------ | ------------------------------------------------------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `label` | `React.ReactNode` | — | Field label. Automatically associated with the text input via `htmlFor` — uses your `id` when provided, otherwise a generated one. While it names the input it also names the open suggestion list, through `aria-labelledby`. Dropped inside an outer `Field`, whose own label associates and names the list instead when that `Field` generates a target id (not `grouped`/`hasAddons`, no explicit `labelProps.htmlFor`). When no label names the input, or the label naming it is an outer `Field`'s wired by hand, the list is named "Suggestions". | +| `labelProps` | `React.LabelHTMLAttributes & { [key: string]: unknown; }` | — | Props for the label element. An explicit `htmlFor` here overrides the automatic association (the input then gets no generated id). While the label names the input, the suggestion list's `aria-labelledby` points at it, through an `id` here when you give one and a generated one otherwise. | +| `id` | `string` | — | Applied to the inner text input (the labellable control), not the wrapper div. | +| `data` | `AutocompleteItem[]` \| `string[]` | `[]` | The options data to display (required). | +| `value` | `string` | — | The current input value (controlled). | +| `selected` | `AutocompleteItem` \| `string` \| `null` | — | The selected item (controlled). | +| `placeholder` | `string` | — | Placeholder text for the input. | +| `field` | `string` | `'label'` | Object property to use as the display field. | +| `clearable` | `boolean` | `false` | Whether to show a clear button while the input holds a value. It hides while the input is disabled, and gives way to a loading spinner at the same right edge: the one `loading` draws, or that of a `Control` with `isLoading` around the Autocomplete. | +| `openOnFocus` | `boolean` | `false` | Open dropdown when input is focused. | +| `keepFirst` | `boolean` | `false` | Keep first option highlighted. | +| `keepOpen` | `boolean` | `false` | Keep dropdown open after selection. | +| `selectOnClickOutside` | `boolean` | `false` | Select highlighted item on click outside. | +| `maxHeight` | `number` | `200` | Maximum dropdown height in pixels. | +| `dropdown` | `boolean` | `false` | Render as dropdown style. | +| `loading` | `boolean` | `false` | Show a loading spinner in the input. Under `prefers-reduced-motion: reduce` the spinner stops and stays drawn (with bestax's CSS loaded). | +| `disabled` | `boolean` | `false` | Whether the input is disabled. | +| `checkInfiniteScroll` | `boolean` | `false` | Enables infinite scroll detection in the dropdown. | +| `infiniteScrollDistance` | `number` | `50` | Distance in pixels from the bottom to trigger `onInfiniteScroll`. | +| `color` | `'primary'` \| `'link'` \| `'info'` \| `'success'` \| `'warning'` \| `'danger'` | — | Input color variant. | +| `size` | `'small'` \| `'medium'` \| `'large'` | — | Size variant. | +| `name` | `string` | — | Form field name. Forwarded to the inner ``. | +| `form` | `string` | — | Optional id of the form the input belongs to. | +| `required` | `boolean` | `false` | Marks the field as required for native HTML form validation. | +| `onInput` | `(value: string) => void` | — | Callback when input value changes. | +| `onSelect` | `(item: AutocompleteItem \| string \| null) => void` | — | Callback when item is selected. | +| `onActiveChange` | `(active: boolean) => void` | — | Callback when dropdown active state changes. | +| `onInfiniteScroll` | `() => void` | — | Callback when scrolled to bottom (infinite scroll). | +| `itemTemplate` | `(item: AutocompleteItem \| string) => React.ReactNode` | — | Custom render for items. | +| `header` | `React.ReactNode` | — | Custom header in dropdown. | +| `footer` | `React.ReactNode` | — | Custom footer in dropdown. | +| `empty` | `React.ReactNode` | — | Content to show when no results. | +| `labelSize` | `'small'` \| `'normal'` \| `'medium'` \| `'large'` | — | Size for the label (used in horizontal layouts). | +| `horizontal` | `boolean` | `false` | Horizontal field layout. | +| `message` | `React.ReactNode` | — | Help/validation message below the input. | +| `messageColor` | `'primary'` \| `'link'` \| `'info'` \| `'success'` \| `'warning'` \| `'danger'` | — | Bulma color for the message. | +| `fieldClassName` | `string` | — | Additional CSS classes for the Field wrapper. | +| `className` | `string` | — | Additional CSS classes. | +| `ref` | `React.Ref` | — | Ref forwarded to the input element. | +| `...` | All standard `
` attributes and Bulma helper props | — | See [Helper Props](../helpers/usebulmaclasses.md) | From ad46c0642f508a466dbac1a393e1aad85c1cc611 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Thu, 8 Oct 2026 21:33:18 -0400 Subject: [PATCH 04/22] fix(bulma-ui): name Autocomplete's list from a hand-wired Field label's id A horizontal form names an Autocomplete in an inner Field by wiring the outer label by hand with labelProps.htmlFor, and there the suggestion list still fell back to "Suggestions", so two such lists shared a name. The Field now passes the id its label renders with alongside the label's htmlFor target, through unlabeled Fields too, and the list points at that id while the label names its input. A hand-wired label carries an id only when the caller gives one in labelProps, so Field's markup is unchanged and a label with no id still leaves the fallback name. The label TSDoc now states when a Field's label names the input and when it names the list, including that an Autocomplete's own id keeps it from adopting the Field's. --- bulma-ui/src/form/Autocomplete.tsx | 13 +++++++------ bulma-ui/src/form/Field.tsx | 20 +++++++++++--------- bulma-ui/src/form/FormContext.tsx | 26 ++++++++++++++++++-------- bulma-ui/src/form/useAutoLabelId.ts | 2 +- 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/bulma-ui/src/form/Autocomplete.tsx b/bulma-ui/src/form/Autocomplete.tsx index 1fa913cea..65f2a13f1 100644 --- a/bulma-ui/src/form/Autocomplete.tsx +++ b/bulma-ui/src/form/Autocomplete.tsx @@ -13,7 +13,7 @@ import { } from '../helpers/classNames'; import { useConfig } from '../helpers/Config'; import { useBulmaClasses, BulmaClassesProps } from '../helpers/useBulmaClasses'; -import { useFieldLabelElementId, useInsideField } from './FormContext'; +import { useFieldLabelFor, useInsideField } from './FormContext'; import { Field } from './Field'; import { FormFieldProps } from './fieldProps'; import { useAutoLabelId } from './useAutoLabelId'; @@ -42,7 +42,7 @@ export interface AutocompleteProps Omit, 'onSelect' | 'onInput'>, Omit, FormFieldProps { - /** Field label. Automatically associated with the text input via `htmlFor` — uses your `id` when provided, otherwise a generated one. While it names the input it also names the open suggestion list, through `aria-labelledby`. Dropped inside an outer `Field`, whose own label associates and names the list instead when that `Field` generates a target id (not `grouped`/`hasAddons`, no explicit `labelProps.htmlFor`). When no label names the input, or the label naming it is an outer `Field`'s wired by hand, the list is named "Suggestions". */ + /** Field label, associated with the text input via `htmlFor`: through your `id` when provided, otherwise a generated one. Dropped inside an outer `Field`, whose label names the input instead when it points at it. The `Field` points its label there itself when the Autocomplete has no `id` and no inner `Field` sits between them (and the `Field` is not `grouped` or `hasAddons`); a label you wire by hand with `labelProps.htmlFor` points at the input with that `id`. The label naming the input also names the open suggestion list, through `aria-labelledby`, when it has an id: this label always does, and an outer `Field`'s does unless you wired it by hand without an `id` in its `labelProps`. Otherwise the list is named "Suggestions". */ label?: React.ReactNode; /** Props for the label element. An explicit `htmlFor` here overrides the automatic association (the input then gets no generated id). While the label names the input, the suggestion list's `aria-labelledby` points at it, through an `id` here when you give one and a generated one otherwise. */ labelProps?: React.LabelHTMLAttributes & { @@ -194,10 +194,11 @@ export const Autocomplete = forwardRef( labelProps, rendersLabel: !insideField, }); - // The id of a surrounding Field's own label, set only while that Field - // wires its label itself, which is when the label renders with this id. - // Undefined under a label wired by hand, which carries no id of ours. - const fieldLabelElementId = useFieldLabelElementId(); + // The id a surrounding Field's label renders with: one the Field generates + // while it wires the label itself, or the caller's `labelProps.id`, which + // is the only id a label wired by hand carries. Read only while that + // label names the input, so it is the label `labelled` checked. + const fieldLabelElementId = useFieldLabelFor().id; const { bulmaHelperClasses, rest } = useBulmaClasses(props); const { classPrefix } = useConfig(); const containerRef = useRef(null); diff --git a/bulma-ui/src/form/Field.tsx b/bulma-ui/src/form/Field.tsx index 8cfcd2c57..46b64abf9 100644 --- a/bulma-ui/src/form/Field.tsx +++ b/bulma-ui/src/form/Field.tsx @@ -1,4 +1,4 @@ -import React, { useId } from 'react'; +import React, { useId, useMemo } from 'react'; import { classNames, usePrefixedClassNames } from '../helpers/classNames'; import { withSubComponents } from '../helpers/withSubComponents'; import { @@ -244,15 +244,17 @@ const FieldComponent: React.FC = ({ // gave it one. const labelId = labelProps?.id ?? (targetId ? `${targetId}-label` : undefined); - // What the rendered label's `for` points at, generated or the caller's, so - // a control can tell when a label wired by hand names it. An unlabeled - // Field passes on its parent's. + // What the rendered label's `for` points at, generated or the caller's, and + // the id it renders with, so a control can tell when a label wired by hand + // names it and point at that label. An unlabeled Field passes on its + // parent's. const inheritedLabelFor = useFieldLabelFor(); - const labelFor = label - ? userWiredLabel - ? labelProps.htmlFor - : targetId - : inheritedLabelFor; + const labelForTarget = userWiredLabel ? labelProps.htmlFor : targetId; + const ownLabelFor = useMemo( + () => ({ htmlFor: labelForTarget, id: labelId }), + [labelForTarget, labelId] + ); + const labelFor = label ? ownLabelFor : inheritedLabelFor; let renderedLabel = null; if (label) { diff --git a/bulma-ui/src/form/FormContext.tsx b/bulma-ui/src/form/FormContext.tsx index 6e55c7dcc..d2d6b701b 100644 --- a/bulma-ui/src/form/FormContext.tsx +++ b/bulma-ui/src/form/FormContext.tsx @@ -250,19 +250,29 @@ export const useFieldLabelElementId = () => /** Provider for the Field label's own id, used internally by Field. */ export const FieldLabelElementIdProvider = FieldLabelElementIdContext.Provider; -const FieldLabelForContext = createContext(undefined); +/** The nearest labeled Field's `