diff --git a/.changepacks/changepack_log_radio_group_a11y.json b/.changepacks/changepack_log_radio_group_a11y.json new file mode 100644 index 000000000..b0c5dad88 --- /dev/null +++ b/.changepacks/changepack_log_radio_group_a11y.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/components/package.json": "Patch" + }, + "note": "RadioGroup is a radiogroup with an optional label, its radios share a name (generated when not given) so arrow keys move between them, and it keeps the typed values of its options: false, 0 and '' stay selectable and onChange receives the option's own value rather than a string. Button-variant radios are visually hidden instead of display:none, so they stay focusable and show a focus ring, and the default radio's focus outline is valid CSS and shown on :focus-visible. Checkbox uses an id it is given for its labels as well as its input, where the labels used to point at a generated id", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_select_a11y.json b/.changepacks/changepack_log_select_a11y.json new file mode 100644 index 000000000..594b6596a --- /dev/null +++ b/.changepacks/changepack_log_select_a11y.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/components/package.json": "Patch" + }, + "note": "Select follows the listbox pattern: the trigger has aria-haspopup, aria-controls and opens with ArrowDown/ArrowUp; the popup is a listbox (aria-multiselectable for checkbox selects) whose options carry role=option, aria-selected and aria-disabled; opening focuses the selected or first option, arrows/Home/End move between enabled options, Enter/Space choose one, and Escape or choosing closes the popup and returns focus to the trigger. An outside click on a controlled select now calls onOpenChange(false) instead of changing hidden internal state", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_toggle_switch.json b/.changepacks/changepack_log_toggle_switch.json new file mode 100644 index 000000000..744f10d0f --- /dev/null +++ b/.changepacks/changepack_log_toggle_switch.json @@ -0,0 +1,7 @@ +{ + "changes": { + "packages/components/package.json": "Patch" + }, + "note": "Toggle is an accessible switch: role=switch with aria-checked, focusable with a visible focus ring, toggled by Space and Enter, labelled through aria-label or aria-labelledby, and submitted under the name it is given; when disabled it leaves the tab order, ignores keys and its hidden input is disabled", + "date": "2026-10-01T00:00:00.000Z" +} diff --git a/.changepacks/changepack_log_ui_kit_behavior.json b/.changepacks/changepack_log_ui_kit_behavior.json new file mode 100644 index 000000000..128cff001 --- /dev/null +++ b/.changepacks/changepack_log_ui_kit_behavior.json @@ -0,0 +1,9 @@ +{ + "changes": { + "packages/components/package.json": "Patch", + "packages/react/package.json": "Patch", + "packages/reset-css/package.json": "Patch" + }, + "note": "Components behave for keyboard, screen readers and both React 18 and 19: Button, Input and Textarea no longer force generic aria-labels (the visible text or an associated label names them), Input and Textarea mark errors with aria-invalid and aria-describedby pointing at the message, a loading Button sets aria-busy, is disabled by default (disableWhileLoading) and announces loadingLabel, Input clear dispatches a real change event instead of a fabricated one, color customization variables sit on the container so every part sees them and every public color key matches its CSS custom property (new keys inputDisabledBackground, inputDisabledText, inputPlaceholder, primaryBackground, Textarea disabledBackground and disabledText), Select positions against the viewport and clears the opposite inset, SelectTrigger asChild composes the child onClick, onKeyDown and ref with its own, Stepper counts without a controlled value, keeps its bound and disabled safeguards and renders its text mode as an output element. Select no longer forces aria-labels over its trigger, options, confirm button and container: an option is named by its content, the listbox by its trigger (aria-labelledby) or by what the caller passes. ThemeScript takes nonce and other script props and never aborts on blocked storage or missing matchMedia, follows the system while no theme was chosen and themes saved in other tabs; getTheme, setTheme and initTheme are safe on the server and initTheme returns a function that stops following. reset-css clips accessible-hidden content as sanitize.css does. No new build errors.", + "date": "2026-10-01T00:00:00.000Z" +} \ No newline at end of file diff --git a/packages/components/src/__tests__/forward-ref.browser.test.tsx b/packages/components/src/__tests__/forward-ref.browser.test.tsx index 59d355290..7b4904913 100644 --- a/packages/components/src/__tests__/forward-ref.browser.test.tsx +++ b/packages/components/src/__tests__/forward-ref.browser.test.tsx @@ -2,9 +2,44 @@ import { describe, expect, it } from 'bun:test' import { render } from 'bun-test-env-dom' import { createRef } from 'react' -import { Button, Input, Radio, Textarea } from '../index' +import { Button, Checkbox, Input, Radio, Textarea } from '../index' describe('forwardRef', () => { + it('links both labels to the forwarded checkbox when a custom id is given', () => { + // Given + const ref = createRef() + + // When + const { container } = render( + + Checkbox + , + ) + + // Then + expect(ref.current?.id).toBe('custom-checkbox') + const labels = container.querySelectorAll('label') + expect(labels).toHaveLength(2) + for (const label of labels) { + expect(label.htmlFor).toBe(ref.current?.id) + } + }) + + it('hands its ref to the native radio when the button variant is used', () => { + // Given + const ref = createRef() + + // When + render( + + Radio + , + ) + + // Then + expect(ref.current?.type).toBe('radio') + }) + it('hands each component ref to its form element', () => { const button = createRef() const input = createRef() diff --git a/packages/components/src/__tests__/index.browser.test.ts b/packages/components/src/__tests__/index.browser.test.ts index 01a760d3a..cc55158f1 100644 --- a/packages/components/src/__tests__/index.browser.test.ts +++ b/packages/components/src/__tests__/index.browser.test.ts @@ -1,29 +1,29 @@ -import { describe, expect, it } from 'bun:test' - -describe('export', () => { - it('should export components', async () => { - const index = await import('../index') - expect({ ...index }).toEqual({ - Button: expect.any(Object), - Input: expect.any(Object), - Textarea: expect.any(Object), - Stepper: expect.any(Function), - Select: expect.any(Function), - Radio: expect.any(Object), - RadioGroup: expect.any(Function), - SelectContainer: expect.any(Function), - SelectDivider: expect.any(Function), - SelectOption: expect.any(Function), - SelectTrigger: expect.any(Function), - StepperContainer: expect.any(Function), - StepperDecreaseButton: expect.any(Function), - StepperIncreaseButton: expect.any(Function), - StepperInput: expect.any(Function), - useStepper: expect.any(Function), - SelectContext: expect.any(Object), - useSelect: expect.any(Function), - Toggle: expect.any(Function), - Checkbox: expect.any(Object), - }) - }) -}) +import { describe, expect, it } from 'bun:test' + +describe('export', () => { + it('should export components', async () => { + const index = await import('../index') + expect({ ...index }).toEqual({ + Button: expect.any(Object), + Input: expect.any(Object), + Textarea: expect.any(Object), + Stepper: expect.any(Function), + Select: expect.any(Function), + Radio: expect.any(Object), + RadioGroup: expect.any(Function), + SelectContainer: expect.any(Function), + SelectDivider: expect.any(Function), + SelectOption: expect.any(Function), + SelectTrigger: expect.any(Object), + StepperContainer: expect.any(Function), + StepperDecreaseButton: expect.any(Function), + StepperIncreaseButton: expect.any(Function), + StepperInput: expect.any(Function), + useStepper: expect.any(Function), + SelectContext: expect.any(Object), + useSelect: expect.any(Function), + Toggle: expect.any(Function), + Checkbox: expect.any(Object), + }) + }) +}) diff --git a/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap index d5ecddeda..2e03d4a14 100644 --- a/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -2,7 +2,7 @@ exports[`Button should render 1`] = ` "
-
" @@ -229,7 +232,7 @@ exports[`Button should render loading spinner when loading is true 1`] = ` exports[`Button should render loading spinner when loading is true and loadingSpinner is partial 1`] = ` "
-
" @@ -246,7 +252,7 @@ exports[`Button should render loading spinner when loading is true and loadingSp exports[`Button should render loading spinner when icon is provided and loading is true 1`] = ` "
-
" diff --git a/packages/components/src/components/Button/__tests__/behavior.browser.test.tsx b/packages/components/src/components/Button/__tests__/behavior.browser.test.tsx new file mode 100644 index 000000000..aad0c0cfd --- /dev/null +++ b/packages/components/src/components/Button/__tests__/behavior.browser.test.tsx @@ -0,0 +1,93 @@ +import { describe, expect, it, mock } from 'bun:test' +import { fireEvent, render } from 'bun-test-env-dom' + +import { Button } from '../index' + +describe('Button accessible name', () => { + it('takes its name from its text, not from a forced label', () => { + const { container } = render() + const button = container.querySelector('button')! + + expect(button).not.toHaveAttribute('aria-label') + expect(button.textContent).toBe('Save changes') + }) + + it('keeps a label given by the caller, as an icon-only button needs', () => { + const { container } = render(, + ) + const button = container.querySelector('button')! + + expect(button).toHaveAttribute('aria-busy', 'true') + expect(button).toBeDisabled() + expect(button).toHaveAttribute('aria-disabled', 'true') + fireEvent.click(button) + expect(onClick).not.toHaveBeenCalled() + }) + + it('tells assistive technology what it is waiting for', () => { + const { container } = render( + , + ) + const status = container.querySelector('[role=status]') + + expect(status?.textContent).toBe('Saving') + }) + + it('says Loading by default and says nothing when it is not loading', () => { + const loading = render() + expect(loading.container.querySelector('[role=status]')?.textContent).toBe( + 'Loading', + ) + + const idle = render() + expect(idle.container.querySelector('[role=status]')).toBeNull() + expect(idle.container.querySelector('button')).not.toHaveAttribute( + 'aria-busy', + ) + expect(idle.container.querySelector('button')).not.toHaveAttribute( + 'aria-disabled', + ) + }) + + it('stays actionable while loading when it is told not to disable', () => { + const onClick = mock() + const { container } = render( + , + ) + const button = container.querySelector('button')! + + expect(button).toHaveAttribute('aria-busy', 'true') + expect(button).not.toBeDisabled() + fireEvent.click(button) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('stays disabled when the caller disables it, loading or not', () => { + const { container } = render( + , + ) + + expect(container.querySelector('button')).toBeDisabled() + }) +}) diff --git a/packages/components/src/components/Button/index.tsx b/packages/components/src/components/Button/index.tsx index 3581d3f05..122fefaf1 100644 --- a/packages/components/src/components/Button/index.tsx +++ b/packages/components/src/components/Button/index.tsx @@ -26,6 +26,10 @@ type ButtonProps = React.ButtonHTMLAttributes & { ellipsis?: boolean loading?: boolean loadingSpinner?: 'whole' | 'partial' + /** Whether a loading button is disabled, so it cannot be activated twice. Defaults to true. */ + disableWhileLoading?: boolean + /** What assistive technology announces while the button is loading */ + loadingLabel?: string } export const Button = forwardRef( @@ -44,10 +48,13 @@ export const Button = forwardRef( disabled, loading = false, loadingSpinner = 'whole', + disableWhileLoading = true, + loadingLabel = 'Loading', ...props }, ref, ) { + const blocked = !!disabled || (loading && disableWhileLoading) return ( ( default: 'var(--inputBackground, #2E2E2E)', }[variant], }} - aria-disabled={disabled} - aria-label="button" + aria-busy={loading || undefined} + aria-disabled={blocked || undefined} bg={ { primary: 'var(--primary, #8163E1)', @@ -196,7 +203,7 @@ export const Button = forwardRef( }[variant] } cursor="pointer" - disabled={disabled} + disabled={blocked} fontSize={ { default: ['14px', null, null, null, '15px'], @@ -267,6 +274,19 @@ export const Button = forwardRef( > {children} + {loading && ( + + {loadingLabel} + + )} ) diff --git a/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap index 9a9418e48..da65dd2fe 100644 --- a/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap +++ b/packages/components/src/components/Checkbox/__tests__/__snapshots__/index.browser.test.tsx.snap @@ -3,10 +3,10 @@ exports[`Checkbox should render basic checkbox 1`] = ` "
-