diff --git a/bestax-mcp/data/catalog.json b/bestax-mcp/data/catalog.json index e63cf4e28..565d17104 100644 --- a/bestax-mcp/data/catalog.json +++ b/bestax-mcp/data/catalog.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "generatedFrom": { "package": "@allxsmith/bestax-bulma", - "version": "5.26.6" + "version": "5.27.0" }, "docsBase": "https://bestax.io/docs", "categories": [ diff --git a/bestax-mcp/data/components/Autocomplete.json b/bestax-mcp/data/components/Autocomplete.json index 55b57b8f1..486c7c9ee 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.\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`\n- Has `aria-haspopup=\"listbox\"` and `aria-autocomplete=\"list\"`\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.\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", "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.", diff --git a/bestax-mcp/data/components/DateTimeInput.json b/bestax-mcp/data/components/DateTimeInput.json index 2a5b6f3e2..30162114e 100644 --- a/bestax-mcp/data/components/DateTimeInput.json +++ b/bestax-mcp/data/components/DateTimeInput.json @@ -170,7 +170,7 @@ "code": "" } ], - "accessibility": "- Trigger uses `role=\"combobox\"` with `aria-haspopup=\"dialog\"`, `aria-expanded`, and `aria-controls`.\n- Popover panel has `role=\"dialog\"` with an accessible name. Opening it puts focus on the focused date.\n- Closing the popover with `Escape`, the ✓ button or `Enter` on a time wheel returns focus to the input, whether the input or the launcher opened it. Under `openOnFocus` that returning focus leaves the popover closed; focusing or clicking the input again opens it. Closing it commits nothing by itself: an empty field stays empty, and leaving afterwards commits only what you typed, so seconds the display leaves out are kept.\n- Calendar uses `role=\"grid\"`; cells expose `aria-selected`, `aria-disabled`, and `aria-current=\"date\"`.\n- Roving `tabindex` keeps a single day focusable at a time, and focus moves with it, inline as in the popover. When the focused date is disabled, that cell is the nearest enabled day of the month.\n- Each time wheel uses `role=\"spinbutton\"` with `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext`.\n- The footer's confirm button exposes an accessible label (`Done`); the Reset button reverts your edits to the value the popover opened with.\n- Tab order walks from the calendar to the footer (Reset / ✓), and from the time wheels to the footer while they are open.", + "accessibility": "- Trigger uses `role=\"combobox\"` with `aria-haspopup=\"dialog\"`, `aria-expanded`, and `aria-controls`.\n- Popover panel has `role=\"dialog\"` with an accessible name. Opening it puts focus on the focused date.\n- Closing the popover with `Escape`, the ✓ button or `Enter` on a time wheel returns focus to the input, whether the input or the launcher opened it. Under `openOnFocus` that returning focus leaves the popover closed; focusing or clicking the input again opens it. Closing it commits nothing by itself: an empty field stays empty, and leaving afterwards commits only what you typed, so seconds the display leaves out are kept.\n- Calendar uses `role=\"grid\"`; cells expose `aria-selected`, `aria-disabled`, and `aria-current=\"date\"`.\n- Roving `tabindex` keeps a single day focusable at a time, and focus moves with it, inline as in the popover. When the focused date is disabled, that cell is the nearest enabled day of the month.\n- Each time wheel uses `role=\"spinbutton\"` with `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext`. The values drawn above and below are click targets hidden from assistive technology, which reads the value from the spinbutton.\n- The footer's confirm button exposes an accessible label (`Done`); the Reset button reverts your edits to the value the popover opened with.\n- Tab order walks from the calendar to the footer (Reset / ✓), and from the time wheels to the footer while they are open.", "related": ["DateInput", "Input", "TimeInput"], "storybook": "https://bestax.io/storybook/?path=/story/form-datetimeinput", "summary": "The `DateTimeInput` combines a calendar and a time **wheel spinner** in a single popover — an iOS-style layout.", diff --git a/bestax-mcp/data/components/Dropdown.json b/bestax-mcp/data/components/Dropdown.json index 35a44c7c4..1bb027f5b 100644 --- a/bestax-mcp/data/components/Dropdown.json +++ b/bestax-mcp/data/components/Dropdown.json @@ -42,7 +42,7 @@ "code": "\n First Item\n Second Item\n \n Third Item\n" } ], - "accessibility": "- The dropdown root is a `
` with ARIA roles/attributes for menu and trigger.\n- The trigger button uses `aria-haspopup`, `aria-controls`, and `aria-expanded`.\n- Menu items are focusable and use `role=\"menuitem\"`. An item given `role=\"menuitemcheckbox\"` or `role=\"menuitemradio\"` stays in the arrow-key order.\n- `Dropdown.Item as=\"button\"` defaults to `type=\"button\"`, so a menu item inside a form doesn't\n submit it. Pass `type=\"submit\"` or `type=\"reset\"` and yours is used instead; any other value\n renders `type=\"button\"`.\n- Clicking outside closes the dropdown in most cases.\n\n`Dropdown` implements the [WAI-ARIA Menu Button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/)\nout of the box:\n\n- On the trigger: ArrowDown or Enter/Space open the menu and\n focus the first item; ArrowUp opens the menu and focuses the last item;\n Escape closes an open menu.\n- Inside the menu: ArrowDown/ArrowUp move between items and wrap around;\n Home/End jump to the first/last item; Escape closes the menu\n and returns focus to the trigger; Tab closes the menu and lets focus continue\n naturally.\n- On an item: Enter or Space activates it the way a click does, so its\n `onClick` runs and the menu closes unless `closeOnClick` is `false`. That includes the\n default anchor without an `href` and `as=\"div\"`, which the browser would not activate on\n its own.\n- When running an item closes the menu, by click or by key, focus goes back to the trigger,\n as it does for Escape. An item whose `onClick` moves focus somewhere else keeps\n it there.\n- Disabled items (a `Dropdown.Item` rendered with a native `disabled` attribute or\n `aria-disabled=\"true\"`) and dividers are skipped during arrow-key navigation.\n\n:::note\nThere is no typeahead and no built-in outside-click handling inside the menu beyond the\nexisting click-outside-to-close behavior — add those yourself if your use case needs them.\n:::", + "accessibility": "- The dropdown root is a `
` with ARIA roles/attributes for menu and trigger.\n- The trigger button uses `aria-haspopup`, `aria-controls`, and `aria-expanded`.\n- Menu items are focusable and use `role=\"menuitem\"`. An item given `role=\"menuitemcheckbox\"` or `role=\"menuitemradio\"` stays in the arrow-key order.\n- The items are the only focusable things inside `role=\"menu\"`. A click between them, on a divider say, focuses the menu itself, so the arrow keys still reach the items from there.\n- `Dropdown.Item as=\"button\"` defaults to `type=\"button\"`, so a menu item inside a form doesn't\n submit it. Pass `type=\"submit\"` or `type=\"reset\"` and yours is used instead; any other value\n renders `type=\"button\"`.\n- Clicking outside closes the dropdown in most cases.\n\n`Dropdown` implements the [WAI-ARIA Menu Button pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/)\nout of the box:\n\n- On the trigger: ArrowDown or Enter/Space open the menu and\n focus the first item; ArrowUp opens the menu and focuses the last item;\n Escape closes an open menu.\n- Inside the menu: ArrowDown/ArrowUp move between items and wrap around;\n Home/End jump to the first/last item; Escape closes the menu\n and returns focus to the trigger; Tab closes the menu and lets focus continue\n naturally.\n- On an item: Enter or Space activates it the way a click does, so its\n `onClick` runs and the menu closes unless `closeOnClick` is `false`. That includes the\n default anchor without an `href` and `as=\"div\"`, which the browser would not activate on\n its own.\n- When running an item closes the menu, by click or by key, focus goes back to the trigger,\n as it does for Escape. An item whose `onClick` moves focus somewhere else keeps\n it there.\n- Disabled items (a `Dropdown.Item` rendered with a native `disabled` attribute or\n `aria-disabled=\"true\"`) and dividers are skipped during arrow-key navigation.\n\n:::note\nThere is no typeahead and no built-in outside-click handling inside the menu beyond the\nexisting click-outside-to-close behavior — add those yourself if your use case needs them.\n:::", "related": ["Button", "Popover", "useBulmaClasses"], "storybook": "https://bestax.io/storybook/?path=/story/components-dropdown--default", "summary": "The `Dropdown` component provides Bulma's versatile dropdown menu for your Bulma React UI.", diff --git a/bestax-mcp/data/components/File.json b/bestax-mcp/data/components/File.json index f3895a58e..b65e4620f 100644 --- a/bestax-mcp/data/components/File.json +++ b/bestax-mcp/data/components/File.json @@ -191,7 +191,7 @@ "name": "iconLeft", "type": "React.ReactNode", "default": null, - "description": "Left icon element. It renders inside the `
- + Saved - + diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.input.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.input.tsx index a6c69051e..453bce795 100644 --- a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.input.tsx +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.input.tsx @@ -27,7 +27,7 @@ export function Uploads({ onPick }: { onPick: () => void }) { Upload a photo - + diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.output.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.output.tsx index d374b2a49..324e6e7db 100644 --- a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.output.tsx +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.output.tsx @@ -18,7 +18,7 @@ export function Uploads({ onPick }: { onPick: () => void }) { accept="image/*" onChange={onPick} buttonLabel="Upload a photo" - iconRight={ + iconRight={} fileName="portrait.png" /> diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/form.output.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/form.output.tsx index f9b7591c1..52eb9fe32 100644 --- a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/form.output.tsx +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/form.output.tsx @@ -1,6 +1,6 @@ import { Button, Control, Field, InputBase, SelectBase, TextAreaBase } from "@allxsmith/bestax-bulma"; export function SignUp() { - // TODO(bestax-migrate): bestax `Icon` renders `aria-label="icon"` when the element does not set it; add it here if that is what you want, then re-run + // TODO(bestax-migrate): bestax `Icon` renders `aria-hidden="true"` when the element does not set it; add it here if that is what you want, then re-run // TODO(bestax-migrate): `.checkbox` stays as markup: bestax `Checkbox` renders its own styled markup, not Bulma's return (
diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.input.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.input.tsx index 9de79570a..d42b8d9fb 100644 --- a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.input.tsx +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.input.tsx @@ -2,43 +2,43 @@ export function IconTexts() { return (
- + Home - + Saved & synced - + Paris - + Budapest - + - + {/* The glyph has an attribute Icon doesn't render. */} - + Info - {/* The icon has no aria-label, which Icon writes. */} + {/* The icon sets no aria-hidden, which Icon writes. */} @@ -47,7 +47,7 @@ export function IconTexts() { {/* IconText renders a , not a
. */}
- + Warning @@ -55,7 +55,7 @@ export function IconTexts() { {/* Text before the icon. */} Next - + diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.output.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.output.tsx index 73d92cb4b..3163b92ed 100644 --- a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.output.tsx +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/icon-text.output.tsx @@ -2,14 +2,14 @@ import { Icon, IconText } from "@allxsmith/bestax-bulma"; export function IconTexts() { // TODO(bestax-migrate): bestax `IconText` builds its icons from props and wraps each text in a of its own, so this converts only when its children are `.icon`s with no `key`, `ref` or dashed attribute but an `aria-` one, each holding one bare, empty that names a Font Awesome or Material Design Icons glyph, and each followed by at most one bare of static text; keep it as markup, or convert it by hand // TODO(bestax-migrate): bestax `IconText` builds each icon from `iconProps`, so this converts only once every `.icon` in it converts to `Icon` on its own; see the TODO on each, then re-run - // TODO(bestax-migrate): bestax `Icon` renders `aria-label="icon"` when the element does not set it; add it here if that is what you want, then re-run + // TODO(bestax-migrate): bestax `Icon` renders `aria-hidden="true"` when the element does not set it; add it here if that is what you want, then re-run // TODO(bestax-migrate): bestax `IconText` renders only , not a
; keep the markup, or change the tag and re-run return (
Home {"Saved & synced"} {/* The glyph has an attribute Icon doesn't render. */} - + Info - {/* The icon has no aria-label, which Icon writes. */} + {/* The icon sets no aria-hidden, which Icon writes. */} @@ -71,7 +74,7 @@ export function IconTexts() { {/* IconText renders a , not a
. */}
- + Warning @@ -79,7 +82,7 @@ export function IconTexts() { {/* Text before the icon. */} Next - + diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.input.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.input.tsx index 7e554be39..a1cbd8416 100644 --- a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.input.tsx +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.input.tsx @@ -15,7 +15,7 @@ export function Sections() {
- + diff --git a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.output.tsx b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.output.tsx index 4d6a0d351..50cb6bad9 100644 --- a/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.output.tsx +++ b/bestax-migrate/src/sources/bulma-classes/__testfixtures__/tabs.output.tsx @@ -1,6 +1,6 @@ import { Icon, Tabs } from "@allxsmith/bestax-bulma"; export function Sections() { - // TODO(bestax-migrate): bestax `Icon` renders `aria-label="icon"` when the element does not set it; add it here if that is what you want, then re-run + // TODO(bestax-migrate): bestax `Icon` renders `aria-hidden="true"` when the element does not set it; add it here if that is what you want, then re-run return (
@@ -17,7 +17,7 @@ export function Sections() { - + diff --git a/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts b/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts index de85b16bf..10cb003ab 100644 --- a/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts +++ b/bestax-migrate/src/sources/bulma-classes/__tests__/plan.test.ts @@ -667,7 +667,7 @@ describe('plan', () => { ).toBe('Tabs'); }); - it('converts .icon only around element children, with its own aria-label', () => { + it('converts .icon only around element children, hidden as Icon hides it', () => { const i = { tag: 'i', attributes: new Map(), @@ -675,19 +675,96 @@ describe('plan', () => { isEmpty: true, }; expect( - plan(facts('span', 'icon', { 'aria-label': 'Home' }, { soleChild: i })) + plan(facts('span', 'icon', { 'aria-hidden': 'true' }, { soleChild: i })) .conversion?.target ).toBe('Icon'); - expect( - plan(facts('span', 'icon', {}, { soleChild: i })).todos.map(t => t.rule) - ).toEqual(['defaults:Icon']); + const bare = plan(facts('span', 'icon', {}, { soleChild: i })); + expect(bare.todos.map(t => t.rule)).toEqual(['defaults:Icon']); + expect(bare.todos[0].message).toMatch( + /renders `aria-hidden="true"` when the element does not set it/ + ); // `{show && }`: Icon would switch to its `name` path when empty. expect( - plan(facts('span', 'icon', { 'aria-label': 'Home' })).todos.map( + plan(facts('span', 'icon', { 'aria-hidden': 'true' })).todos.map( t => t.rule ) ).toEqual(['children:Icon']); }); + + describe('.icon with a name', () => { + const i = { + tag: 'i', + attributes: new Map(), + hasSpread: false, + isEmpty: true, + }; + const icon = (attributes: Record) => + plan(facts('span', 'icon', attributes, { soleChild: i })); + + it.each(['aria-label', 'aria-labelledby'])( + 'converts one named by %s only as the image Icon makes it', + name => { + expect(icon({ [name]: 'home', role: 'img' }).conversion?.target).toBe( + 'Icon' + ); + // It needs no aria-hidden then: Icon renders none on a named icon. + const unset = icon({ [name]: 'home', 'aria-hidden': 'true' }); + expect(unset.todos.map(t => t.rule)).toEqual(['defaults:Icon']); + expect(unset.todos[0].message).toMatch( + /renders `role="img"` when the element does not set it/ + ); + } + ); + + it('reads a bare attribute as a name, and an empty one as none', () => { + expect(icon({ 'aria-label': true }).todos[0].message).toMatch( + /`role="img"`/ + ); + expect( + icon({ 'aria-label': '', 'aria-hidden': 'true' }).conversion?.target + ).toBe('Icon'); + expect(icon({ 'aria-label': '' }).todos[0].message).toMatch( + /`aria-hidden="true"`/ + ); + }); + + it('needs no aria-hidden on an unnamed icon with a role of its own', () => { + // Icon leaves an unnamed icon with a role visible, an icon made a + // button, say. + expect(icon({ role: 'button' }).conversion?.target).toBe('Icon'); + // A role given as an expression may render as none, and then Icon + // hides the icon, so it still needs aria-hidden written out. + expect(icon({ role: null }).todos.map(t => t.rule)).toEqual([ + 'defaults:Icon', + ]); + }); + + it('needs no aria-hidden on an unnamed icon with a tabIndex of its own', () => { + // Icon leaves an unnamed icon that can take focus visible. + expect(icon({ tabIndex: '0' }).conversion?.target).toBe('Icon'); + expect(icon({ tabIndex: null }).todos.map(t => t.rule)).toEqual([ + 'defaults:Icon', + ]); + }); + + it('needs a role when the name is an expression', () => { + // Named or not, an icon with a role of its own keeps it and isn't + // hidden, so a role written out makes either outcome render as given. + expect( + icon({ 'aria-label': null, role: 'img' }).conversion?.target + ).toBe('Icon'); + const half = icon({ 'aria-label': null, 'aria-hidden': 'false' }); + expect(half.todos.map(t => t.rule)).toEqual(['defaults:Icon']); + expect(half.todos[0].message).toBe( + 'bestax `Icon` renders `role="img"` when it has a name and `aria-hidden="true"` when it has none, and this element\'s `aria-label` is an expression that may render as either; set `role` here to what you want, then re-run' + ); + // A name written out settles it, whatever the other one is. + expect( + icon({ 'aria-label': null, 'aria-labelledby': 'x', role: 'img' }) + .conversion?.target + ).toBe('Icon'); + }); + }); }); describe('Panel', () => { @@ -803,7 +880,9 @@ describe('plan', () => { /** A `.icon` as the transform hands it over: planned on its own first. */ const icon = ( glyph = ['fas', 'fa-home'], - attributes: Record = { 'aria-label': 'x' }, + attributes: Record = { + 'aria-hidden': 'true', + }, tokens: string[] = [] ): ChildFacts => { const own = facts('span', ['icon', ...tokens].join(' '), attributes, { @@ -863,11 +942,11 @@ describe('plan', () => { const conversion = iconText( [ icon(['far', 'fa-bell', 'fa-lg', 'fa-fw'], { - 'aria-label': 'x', + 'aria-hidden': 'true', tabIndex: '0', }), text('a'), - icon(['mdi', 'mdi-home'], { 'aria-label': 'x' }, [ + icon(['mdi', 'mdi-home'], { 'aria-hidden': 'true' }, [ 'is-small', 'my-icon', ]), @@ -938,15 +1017,20 @@ describe('plan', () => { ['a glyph Icon reads no name from', [icon(['bi', 'bi-house'])]], [ 'an icon with a key', - [icon(['fas', 'fa-home'], { 'aria-label': 'x', key: 'k' })], + [icon(['fas', 'fa-home'], { 'aria-hidden': 'true', key: 'k' })], ], [ 'an icon with a ref', - [icon(['fas', 'fa-home'], { 'aria-label': 'x', ref: null })], + [icon(['fas', 'fa-home'], { 'aria-hidden': 'true', ref: null })], ], [ 'an icon with a data attribute', - [icon(['fas', 'fa-home'], { 'aria-label': 'x', 'data-test': 'x' })], + [ + icon(['fas', 'fa-home'], { + 'aria-hidden': 'true', + 'data-test': 'x', + }), + ], ], ]; for (const [label, children] of shaped) { @@ -961,7 +1045,7 @@ describe('plan', () => { expect( iconText([ icon(['fas', 'fa-home'], { - 'aria-label': 'x', + 'aria-hidden': 'true', 'aria-describedby': 'y', }), ]).conversion @@ -969,7 +1053,7 @@ describe('plan', () => { }); it('waits for each icon to convert on its own, unless another child does', () => { - // No aria-label, which Icon writes: the icon stays, and says so. + // No aria-hidden, which Icon writes: the icon stays, and says so. const bare = icon(['fas', 'fa-home'], {}); expect(bare.becomes).toBeUndefined(); const waits = iconText([bare, text()]); diff --git a/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts b/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts index e885fa872..fbc7f7634 100644 --- a/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts +++ b/bestax-migrate/src/sources/bulma-classes/__tests__/transform.test.ts @@ -1192,7 +1192,7 @@ describe('an element whose component builds its icons from props', () => { const iconText = (icon: string, text = 'Home') => `export const A = (label: string) => (\n \n ${icon}\n ${text}\n \n);\n`; const home = - ''; + ''; it('imports the component alone, since its icons go into it', () => { const { output, rules } = migrate(iconText(home)); @@ -1206,10 +1206,13 @@ describe('an element whose component builds its icons from props', () => { it("writes each of an icon's attributes as `Icon` is given it", () => { const { output } = migrate( iconText( - '' + '' ) ); - expect(output).toMatch(/ariaLabel: label,/); + // `aria-label` keeps its name: an empty `ariaLabel` would render none. + expect(output).toMatch(/"aria-label": label,/); + expect(output).toMatch(/role: "img",/); + expect(output).toMatch(/"aria-hidden": !label,/); expect(output).toMatch(/title: "Go & see",/); expect(output).toMatch(/tabIndex: 0,/); expect(output).toMatch(/style: \{\s*color: "red"\s*\},/); @@ -1228,7 +1231,7 @@ describe('an element whose component builds its icons from props', () => { it('moves the comments in its children to the name that stays', () => { const { output } = migrate( iconText( - '' + '' ) ); expect(output).toMatch(/ { it('keeps the TODO an icon it takes carried, with the class', () => { const { output, rules } = migrate( iconText( - '' + '' ) ); expect(rules).toEqual(['legacy:tile']); diff --git a/bestax-migrate/src/sources/bulma-classes/class-map.ts b/bestax-migrate/src/sources/bulma-classes/class-map.ts index 9d546708e..ec6807909 100644 --- a/bestax-migrate/src/sources/bulma-classes/class-map.ts +++ b/bestax-migrate/src/sources/bulma-classes/class-map.ts @@ -138,6 +138,23 @@ export interface RootEntry { readonly bgColor?: 'bgColor' | 'backgroundColor' | null; /** Attributes the target renders when the element does not set them. */ readonly defaults?: Readonly>; + /** + * What the target renders in place of `defaults` once the element names + * itself through one of `by`, set to anything but an empty string (`Icon` + * makes a named icon an image and hides any other). A name given as an + * expression may render as none, so the element then sets both. + */ + readonly namedDefaults?: { + readonly by: readonly string[]; + readonly defaults: Readonly>; + }; + /** + * Attributes that stop the target rendering `defaults` when the element sets + * one to a value written out (`Icon` hides an unnamed icon only when it has + * no `role` or `tabIndex` of its own). An expression may render as nothing, + * so it stops nothing. + */ + readonly defaultsUnless?: readonly string[]; /** * An attribute the target always writes on the tags in `on`: `fallback` * when the element sets none, or sets it to anything but one of `keeps` @@ -1762,14 +1779,20 @@ export const ROOTS: Readonly> = { 'dropdown-content': part(), 'dropdown-item': part(), 'dropdown-divider': part(), - // With children, `Icon` renders `.icon` around them as given, and writes an - // `aria-label` (`"icon"` unless it's given one). + // With children, `Icon` renders `.icon` around them as given. It hides an + // icon with no name, no `role` and no `tabIndex`, and makes one named by + // `aria-label` or `aria-labelledby` an image. icon: { ...BASE, target: 'Icon', tag: 'span', modifiers: tokens('is-', ['small', 'medium', 'large'], 'size'), - defaults: { 'aria-label': 'icon' }, + defaults: { 'aria-hidden': 'true' }, + namedDefaults: { + by: ['aria-label', 'aria-labelledby'], + defaults: { role: 'img' }, + }, + defaultsUnless: ['role', 'tabIndex'], requiresChildren: true, needsElementChildren: true, ownProps: [ diff --git a/bestax-migrate/src/sources/bulma-classes/plan.ts b/bestax-migrate/src/sources/bulma-classes/plan.ts index 0750d608f..4e946a40c 100644 --- a/bestax-migrate/src/sources/bulma-classes/plan.ts +++ b/bestax-migrate/src/sources/bulma-classes/plan.ts @@ -542,20 +542,34 @@ export function plan(facts: ElementFacts): Plan { const written = Object.entries(entry.writesAttr ?? {}) .filter(([, rule]) => rule.on.includes(tag)) .map(([name, rule]): [string, string] => [name, rule.fallback]); - const missing = [...Object.entries(entry.defaults ?? {}), ...written].filter( + const naming = namingOf(entry, attributes); + // An attribute written out that stops the target's defaults, such as + // `Icon`'s own `role` or `tabIndex`, leaves an unnamed element nothing to + // add. + const stopped = entry.defaultsUnless?.some( + name => attributes.has(name) && attributes.get(name) !== null + ); + const unnamed = stopped ? {} : (entry.defaults ?? {}); + const defaults: Readonly> = + naming === 'named' + ? entry.namedDefaults!.defaults + : naming === 'unsure' + ? { ...unnamed, ...entry.namedDefaults!.defaults } + : unnamed; + const missing = [...Object.entries(defaults), ...written].filter( ([name]) => !attributes.has(name) ); const drop: string[] = []; for (const name of entry.untypedAttrs ?? []) { if (!attributes.has(name)) continue; - if (entry.defaults?.[name] === attributes.get(name)) { + if (defaults[name] === attributes.get(name)) { drop.push(name); continue; } return refuse( 'attr', name, - `bestax \`${target}\`'s props take no \`${name}\`${entry.defaults?.[name] ? `, and it renders \`${name}="${entry.defaults[name]}"\` when none is given` : ''}; keep this element as markup` + `bestax \`${target}\`'s props take no \`${name}\`${defaults[name] ? `, and it renders \`${name}="${defaults[name]}"\` when none is given` : ''}; keep this element as markup` ); } const numbers: string[] = []; @@ -680,6 +694,19 @@ export function plan(facts: ElementFacts): Plan { `bestax \`${target}\` renders this element's children itself, \`${counts.prop}\` bare, empty <${counts.tag}>s, so it converts only when its children are just that; keep it as markup` ); } + if (missing.length > 0 && naming === 'unsure') { + const list = (set: Readonly> = {}) => + Object.entries(set) + .map(([name, value]) => `\`${name}="${value}"\``) + .join(' and '); + const by = entry.namedDefaults!.by.find(name => attributes.has(name)); + const add = missing.map(([name]) => `\`${name}\``).join(' and '); + return refuse( + 'defaults', + target, + `bestax \`${target}\` renders ${list(entry.namedDefaults!.defaults)} when it has a name and ${list(entry.defaults)} when it has none, and this element's \`${by}\` is an expression that may render as either; set ${add} here to what you want, then re-run` + ); + } if (missing.length > 0) { const list = missing.map(([name, value]) => `\`${name}="${value}"\``); return refuse( @@ -922,6 +949,25 @@ function buildFile( }; } +/** + * Whether the element names itself through one of `entry.namedDefaults.by`, + * which picks the defaults its target renders: a string written out names + * it unless empty, as a bare attribute does, and an expression may or may + * not. An entry with no `namedDefaults` is never named. + */ +function namingOf( + entry: RootEntry, + attributes: ReadonlyMap +): 'named' | 'unnamed' | 'unsure' { + const given = (entry.namedDefaults?.by ?? []) + .filter(name => attributes.has(name)) + .map(name => attributes.get(name)); + if (given.some(value => value === true || (value !== '' && value !== null))) { + return 'named'; + } + return given.includes(null) ? 'unsure' : 'unnamed'; +} + /** * The icons a target that `buildsIcons` builds from an element's children: * each `.icon` that converts to `Icon` on its own and holds a glyph `Icon` diff --git a/bestax-migrate/src/sources/bulma-classes/rules.ts b/bestax-migrate/src/sources/bulma-classes/rules.ts index 03328c4bc..58fce9e9b 100644 --- a/bestax-migrate/src/sources/bulma-classes/rules.ts +++ b/bestax-migrate/src/sources/bulma-classes/rules.ts @@ -46,6 +46,8 @@ const VOCABULARY: ReadonlySet = new Set([ ...(entry.untypedAttrs ?? []), ...(entry.numberAttrs ?? []), ...Object.keys(entry.defaults ?? {}), + ...Object.keys(entry.namedDefaults?.defaults ?? {}), + ...(entry.defaultsUnless ?? []), ...Object.keys(entry.dropsAttr ?? {}), ...Object.keys(entry.writesAttr ?? {}), ]), diff --git a/bestax-migrate/src/sources/bulma-classes/transform.ts b/bestax-migrate/src/sources/bulma-classes/transform.ts index 503791d98..b8747c440 100644 --- a/bestax-migrate/src/sources/bulma-classes/transform.ts +++ b/bestax-migrate/src/sources/bulma-classes/transform.ts @@ -823,7 +823,8 @@ function propValue( /** * The props one built icon is given, as an object: its glyph's and its * classes' first, then its `.icon`'s attributes, as `Icon` would have been - * given them, with `aria-label` under the name `Icon` declares for it. + * given them. `aria-label` keeps its own name: `Icon` reads an empty + * `ariaLabel` as no name and renders no `aria-label` for it at all. */ function iconObject(j: any, icon: any, built: BuiltIcon): any { const properties = built.props.map(([name, value]) => @@ -850,12 +851,7 @@ function iconObject(j: any, icon: any, built: BuiltIcon): any { : given.type === 'JSXExpressionContainer' ? given.expression : propValue(j, given.value, built.numbers.includes(name)); - properties.push( - j.objectProperty( - propKey(j, name === 'aria-label' ? 'ariaLabel' : name), - value - ) - ); + properties.push(j.objectProperty(propKey(j, name), value)); } return j.objectExpression(properties); } diff --git a/bulma-ui/src/components/Dropdown.tsx b/bulma-ui/src/components/Dropdown.tsx index fdfa1d8b3..bea9bbc0b 100644 --- a/bulma-ui/src/components/Dropdown.tsx +++ b/bulma-ui/src/components/Dropdown.tsx @@ -183,7 +183,9 @@ const DropdownComponent = forwardRef( // Closing hides the item that has focus, and the browser drops focus // to the page when that happens. Hand it back to the trigger, as // Escape does. An item that moved focus somewhere else keeps it there. - if (e.currentTarget.contains(getActiveElementInTree(e.currentTarget))) { + // The menu around this content is what a click between items focuses. + const menu = e.currentTarget.parentElement as HTMLElement; + if (menu.contains(getActiveElementInTree(e.currentTarget))) { triggerRef.current?.focus(); } } @@ -399,11 +401,13 @@ const DropdownComponent = forwardRef( role="menu" data-testid="dropdown-menu" onKeyDown={handleMenuKeyDown} + // The menu, not its content, takes focus from a click between items, + // so the menu owns nothing focusable but its items. + tabIndex={-1} >
{children}
diff --git a/bulma-ui/src/components/__tests__/Dropdown.test.tsx b/bulma-ui/src/components/__tests__/Dropdown.test.tsx index a969a8eaf..4b6bee347 100644 --- a/bulma-ui/src/components/__tests__/Dropdown.test.tsx +++ b/bulma-ui/src/components/__tests__/Dropdown.test.tsx @@ -470,6 +470,38 @@ describe('Dropdown', () => { removeEventListenerSpy.mockRestore(); }); + test('the menu owns nothing focusable but its items', () => { + render( + + Item 1 + + ); + const menu = screen.getByRole('menu'); + const content = menu.querySelector('.dropdown-content')!; + expect(content).not.toHaveAttribute('tabindex'); + Array.from(menu.querySelectorAll('[tabindex]')).forEach(el => + expect(el).toHaveAttribute('role', 'menuitem') + ); + }); + + test('a click between items keeps focus in the menu while it stays open', async () => { + const user = userEvent.setup(); + render( + + Item 1 + + Item 2 + + ); + await user.click(screen.getByRole('button', { name: 'Menu' })); + await user.click(screen.getByRole('separator')); + const menu = screen.getByRole('menu'); + expect(menu).toHaveFocus(); + // The arrow keys still reach the items from there. + await user.keyboard('{ArrowDown}'); + expect(screen.getByText('Item 1')).toHaveFocus(); + }); + test('handleMenuClick does nothing when closeOnClick is falsy', () => { render( @@ -1584,6 +1616,21 @@ describe('Dropdown focus after an item runs', () => { expect(screen.getByRole('button', { name: 'Menu' })).toHaveFocus(); }); + test('a click between items hands focus back to the trigger too', async () => { + const user = userEvent.setup(); + render( + + Archive + + Delete + + ); + await openMenu(user); + await user.click(screen.getByRole('separator')); + expect(screen.getByTestId('dropdown-root')).not.toHaveClass('is-active'); + expect(screen.getByRole('button', { name: 'Menu' })).toHaveFocus(); + }); + test('an item that moves focus elsewhere keeps it there', async () => { const user = userEvent.setup(); render( diff --git a/bulma-ui/src/elements/Icon.stories.tsx b/bulma-ui/src/elements/Icon.stories.tsx index ebd22d348..f7b881627 100644 --- a/bulma-ui/src/elements/Icon.stories.tsx +++ b/bulma-ui/src/elements/Icon.stories.tsx @@ -1,5 +1,7 @@ import { Meta, StoryObj } from '@storybook/react-vite'; import { Icon } from './Icon'; +import { Button } from './Button'; +import { Buttons } from './Buttons'; import { validColors, validSizes, @@ -239,3 +241,29 @@ export const CustomNode: Story = { }, }, }; + +export const DecorativeAndNamed: Story = { + render: () => ( + + + + + + ), + parameters: { + docs: { + description: { + story: + 'An `Icon` with no `ariaLabel` is decorative: it renders `aria-hidden="true"` and adds nothing to the name of the button around it, so these read as "Save" and "Delete item". Give it an `ariaLabel` and it becomes an image with that name, so the last reads as "Warning Unsaved changes".', + }, + }, + }, +}; diff --git a/bulma-ui/src/elements/Icon.tsx b/bulma-ui/src/elements/Icon.tsx index 4abdc1fb2..df622c776 100644 --- a/bulma-ui/src/elements/Icon.tsx +++ b/bulma-ui/src/elements/Icon.tsx @@ -75,13 +75,16 @@ interface IconBaseProps /** Size modifier for the icon container. */ size?: 'small' | 'medium' | 'large'; /** - * ARIA label for accessibility. Applied unconditionally to the container span, so an - * icon that never sets it still renders `aria-label="icon"`. For an icon-only control, - * put the real `aria-label` on the control (e.g. `Button`) and set `aria-hidden` here - * instead — otherwise the control's accessible name is announced as just "icon". For a - * decorative icon beside visible text, set `aria-hidden` here rather than leaving the - * default, so it doesn't add "icon" to the text's accessible name. - * @defaultValue 'icon' + * Accessible name for an icon that means something on its own. Set it and the container + * span gets `role="img"` with this `aria-label`, so the name is exactly this text: a + * Material ligature such as `rocket_launch` inside stays out of it. Leave it unset (or + * empty) and the icon is decorative: the span renders `aria-hidden="true"` and no name, + * so it adds nothing to the accessible name of a `Button` or text around it. For an + * icon-only control, put the name on the control (e.g. `Button`'s `aria-label`) and leave + * this unset. A non-empty native `aria-label` or `aria-labelledby` passed instead also + * counts as a name, and an explicit `role` or `aria-hidden` wins over both defaults. An + * unnamed icon given a `role` or a `tabIndex` of its own isn't hidden, since hiding + * something focusable is a failure of its own. */ ariaLabel?: string; /** Inline style object. */ @@ -274,7 +277,7 @@ export const Icon: React.FC = ({ features, libraryFeatures, // Deprecated but maintained for backward compatibility size, - ariaLabel = 'icon', + ariaLabel, style, icon, // Capture and exclude the deprecated 'icon' prop from DOM color: _color, // Exclude 'color' prop if passed directly @@ -315,13 +318,27 @@ export const Icon: React.FC = ({ className ); + // A named icon is an image, whose name is its label alone, so a ligature inside + // never leaks into it. An unnamed one is decorative and hidden outright. Spread + // before `rest`, so a caller's own `role` or `aria-hidden` still wins. + const named = + !!ariaLabel || !!restProps['aria-label'] || !!restProps['aria-labelledby']; + // A caller's own `role` or `tabIndex` on an unnamed icon means it isn't decoration (an + // icon made a button, say), so it isn't hidden either: `aria-hidden` on something + // focusable is a failure of its own. + const a11yProps = named + ? { role: 'img', 'aria-label': ariaLabel || undefined } + : restProps.role !== undefined || restProps.tabIndex !== undefined + ? {} + : { 'aria-hidden': true }; + if (children !== undefined) { // `IconChildrenProps`: render the caller's node (an inline SVG, a `react-icons` // component, …) in place of a class-based glyph. Library/variant/features don't apply. return ( @@ -366,7 +383,7 @@ export const Icon: React.FC = ({ return ( @@ -444,7 +461,7 @@ export const Icon: React.FC = ({ return ( @@ -469,7 +486,7 @@ export const Icon: React.FC = ({ return ( @@ -481,7 +498,7 @@ export const Icon: React.FC = ({ return ( diff --git a/bulma-ui/src/elements/__tests__/Icon.test.tsx b/bulma-ui/src/elements/__tests__/Icon.test.tsx index 2ae8e6079..829574575 100644 --- a/bulma-ui/src/elements/__tests__/Icon.test.tsx +++ b/bulma-ui/src/elements/__tests__/Icon.test.tsx @@ -1,7 +1,11 @@ import { render, screen } from '@testing-library/react'; import { Icon } from '../Icon'; +import { Button } from '../Button'; import { ConfigProvider } from '../../helpers/Config'; +/** The icon's container span: the first span on the page. */ +const iconSpan = () => document.body.querySelector('span') as HTMLElement; + describe('Icon', () => { it('renders a Font Awesome icon by default', () => { const { container } = render(); @@ -95,7 +99,7 @@ describe('Icon', () => { it('applies Bulma size modifier', () => { render(); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('icon'); expect(span).toHaveClass('is-large'); }); @@ -104,7 +108,7 @@ describe('Icon', () => { render( ); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('icon'); expect(span).toHaveClass('my-custom-class'); // m="2" and textColor="primary" should result in Bulma classes @@ -297,7 +301,7 @@ describe('Icon', () => { expect(i).toHaveClass('material-symbols-outlined', 'is-size-1'); expect(i).toHaveTextContent('settings'); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('icon', 'is-large'); }); @@ -408,7 +412,7 @@ describe('Icon', () => { ); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('my-prefix-icon'); }); @@ -418,7 +422,7 @@ describe('Icon', () => { ); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('icon'); }); @@ -428,7 +432,7 @@ describe('Icon', () => { ); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('icon'); }); @@ -439,7 +443,7 @@ describe('Icon', () => { ); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('bulma-icon'); expect(span).toHaveClass('bulma-is-large'); expect(span).toHaveClass('bulma-m-2'); @@ -448,7 +452,7 @@ describe('Icon', () => { it('works without prefix', () => { render(); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('icon'); expect(span).toHaveClass('is-medium'); expect(span).toHaveClass('p-3'); @@ -486,21 +490,21 @@ describe('Icon', () => { ); // No name was derived (legacy parser only handles strings) and the // component still renders without crashing. - expect(screen.getByLabelText('icon')).toBeInTheDocument(); + expect(iconSpan()).toBeInTheDocument(); }); }); describe('containerClassName override', () => { it('uses containerClassName instead of the default `icon` class', () => { render(); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('panel-icon'); expect(span).not.toHaveClass('icon'); }); it('appends a separate is-${size} modifier when containerClassName + size are both set', () => { render(); - const span = screen.getByLabelText('icon'); + const span = iconSpan(); expect(span).toHaveClass('panel-icon'); expect(span).toHaveClass('is-small'); expect(span).not.toHaveClass('icon'); @@ -569,4 +573,144 @@ describe('Icon', () => { expect(container.querySelector('i')).not.toBeInTheDocument(); }); }); + + describe('Accessibility', () => { + it.each([ + ['a Font Awesome glyph', ], + [ + 'a Material ligature', + , + ], + ['an Ionicon', ], + [ + 'a custom node', + + + , + ], + ['an empty icon string', ], + ])('hides %s with no name by default', (_name, icon) => { + render(icon); + const span = iconSpan(); + expect(span).toHaveAttribute('aria-hidden', 'true'); + expect(span).not.toHaveAttribute('aria-label'); + expect(span).not.toHaveAttribute('role'); + }); + + it('treats an empty ariaLabel as decorative', () => { + render(); + expect(iconSpan()).toHaveAttribute('aria-hidden', 'true'); + expect(iconSpan()).not.toHaveAttribute('aria-label'); + }); + + it('treats an empty native aria-label as decorative too', () => { + render(); + expect(iconSpan()).toHaveAttribute('aria-hidden', 'true'); + expect(iconSpan()).not.toHaveAttribute('role'); + }); + + it('makes a named icon an image named by its label alone', () => { + render(); + const img = screen.getByRole('img', { name: 'Favourite' }); + expect(img).toBe(iconSpan()); + expect(img).not.toHaveAttribute('aria-hidden'); + }); + + it('keeps a Material ligature out of a named icon', () => { + render( + + ); + expect(screen.getByRole('img')).toHaveAccessibleName('Launch'); + }); + + it('adds nothing to the name of a button around a decorative icon', () => { + render( + + ); + expect(screen.getByRole('button')).toHaveAccessibleName('Quick Start'); + }); + + it('names a button around a named icon with the label, not the ligature', () => { + render( + + ); + expect(screen.getByRole('button')).toHaveAccessibleName( + 'Launch Quick Start' + ); + }); + + it('counts a native aria-label as a name', () => { + render(); + const img = screen.getByRole('img', { name: 'Close' }); + expect(img).not.toHaveAttribute('aria-hidden'); + }); + + it('counts a native aria-labelledby as a name', () => { + render( + <> + +

Close

+ + ); + const img = screen.getByRole('img', { name: 'Close' }); + expect(img).not.toHaveAttribute('aria-hidden'); + expect(img).not.toHaveAttribute('aria-label'); + }); + + it('lets a native aria-label win over ariaLabel, as before', () => { + render(); + expect(screen.getByRole('img')).toHaveAccessibleName('Close'); + }); + + it('leaves an unnamed icon with a role of its own visible', () => { + render(); + const span = iconSpan(); + expect(span).toHaveAttribute('role', 'button'); + expect(span).not.toHaveAttribute('aria-hidden'); + }); + + it.each([0, -1])( + 'leaves an unnamed icon with a tabIndex of %s visible, since it can take focus', + tabIndex => { + render(); + expect(iconSpan()).not.toHaveAttribute('aria-hidden'); + } + ); + + it('still hides an unnamed icon whose role is spread as undefined', () => { + const spread: { role?: string } = { role: undefined }; + render(); + expect(iconSpan()).toHaveAttribute('aria-hidden', 'true'); + }); + + it('lets an explicit aria-hidden or role win over the defaults', () => { + render( + <> +
); - const icon = screen.getByLabelText('icon'); + const icon = document.body.querySelector('span.icon'); expect(icon).toHaveClass('icon is-large has-text-danger'); }); diff --git a/bulma-ui/src/form/Autocomplete.tsx b/bulma-ui/src/form/Autocomplete.tsx index 2aa964ab9..e20148c1b 100644 --- a/bulma-ui/src/form/Autocomplete.tsx +++ b/bulma-ui/src/form/Autocomplete.tsx @@ -4,6 +4,7 @@ import React, { useRef, useEffect, useCallback, + useId, } from 'react'; import { classNames, @@ -202,6 +203,7 @@ export const Autocomplete = forwardRef( const [internalValue, setInternalValue] = useState(''); const [isActive, setIsActive] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); + const listboxId = `${useId()}-listbox`; // Use controlled or internal value const inputValue = @@ -452,6 +454,21 @@ export const Autocomplete = forwardRef( const showClear = 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. + const listOpen = isActive && (filteredData.length > 0 || !!empty); + const listLabelId = + !insideField && label + ? ((fieldLabelProps?.id as string | undefined) ?? `${listboxId}-label`) + : undefined; + const activeOptionId = + listOpen && + highlightedIndex >= 0 && + highlightedIndex < filteredData.length + ? `${listboxId}-${highlightedIndex}` + : undefined; + const autocompleteElement = (
@@ -470,7 +487,9 @@ export const Autocomplete = forwardRef( onFocus={handleFocus} onKeyDown={handleKeyDown} role="combobox" - aria-expanded={isActive} + aria-expanded={listOpen} + aria-controls={listOpen ? listboxId : undefined} + aria-activedescendant={activeOptionId} aria-haspopup="listbox" aria-autocomplete="list" autoComplete="off" @@ -504,13 +523,16 @@ export const Autocomplete = forwardRef( )}
- {isActive && (filteredData.length > 0 || empty) && ( + {listOpen && (
{header &&
{header}
} @@ -532,6 +554,7 @@ export const Autocomplete = forwardRef( return (
!isDisabled && handleSelect(item)} @@ -564,7 +587,11 @@ export const Autocomplete = forwardRef( diff --git a/bulma-ui/src/form/File.tsx b/bulma-ui/src/form/File.tsx index 846f54833..bbaee81b3 100644 --- a/bulma-ui/src/form/File.tsx +++ b/bulma-ui/src/form/File.tsx @@ -73,13 +73,13 @@ export interface FileProps buttonLabel?: React.ReactNode; /** * Left icon element. It renders inside the `