Skip to content

Commit a26bb57

Browse files
authored
Merge pull request #741 from dev-five-git/fix/ui-kit-behavior
fix(components): make the UI kit, theme helpers and reset-css behave for real users
2 parents 62bbe9c + dfdf810 commit a26bb57

34 files changed

Lines changed: 3325 additions & 1620 deletions
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"changes": {
3+
"packages/components/package.json": "Patch",
4+
"packages/react/package.json": "Patch",
5+
"packages/reset-css/package.json": "Patch"
6+
},
7+
"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.",
8+
"date": "2026-10-01T00:00:00.000Z"
9+
}
Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
1-
import { describe, expect, it } from 'bun:test'
2-
3-
describe('export', () => {
4-
it('should export components', async () => {
5-
const index = await import('../index')
6-
expect({ ...index }).toEqual({
7-
Button: expect.any(Object),
8-
Input: expect.any(Object),
9-
Textarea: expect.any(Object),
10-
Stepper: expect.any(Function),
11-
Select: expect.any(Function),
12-
Radio: expect.any(Object),
13-
RadioGroup: expect.any(Function),
14-
SelectContainer: expect.any(Function),
15-
SelectDivider: expect.any(Function),
16-
SelectOption: expect.any(Function),
17-
SelectTrigger: expect.any(Function),
18-
StepperContainer: expect.any(Function),
19-
StepperDecreaseButton: expect.any(Function),
20-
StepperIncreaseButton: expect.any(Function),
21-
StepperInput: expect.any(Function),
22-
useStepper: expect.any(Function),
23-
SelectContext: expect.any(Object),
24-
useSelect: expect.any(Function),
25-
Toggle: expect.any(Function),
26-
Checkbox: expect.any(Object),
27-
})
28-
})
29-
})
1+
import { describe, expect, it } from 'bun:test'
2+
3+
describe('export', () => {
4+
it('should export components', async () => {
5+
const index = await import('../index')
6+
expect({ ...index }).toEqual({
7+
Button: expect.any(Object),
8+
Input: expect.any(Object),
9+
Textarea: expect.any(Object),
10+
Stepper: expect.any(Function),
11+
Select: expect.any(Function),
12+
Radio: expect.any(Object),
13+
RadioGroup: expect.any(Function),
14+
SelectContainer: expect.any(Function),
15+
SelectDivider: expect.any(Function),
16+
SelectOption: expect.any(Function),
17+
SelectTrigger: expect.any(Object),
18+
StepperContainer: expect.any(Function),
19+
StepperDecreaseButton: expect.any(Function),
20+
StepperIncreaseButton: expect.any(Function),
21+
StepperInput: expect.any(Function),
22+
useStepper: expect.any(Function),
23+
SelectContext: expect.any(Object),
24+
useSelect: expect.any(Function),
25+
Toggle: expect.any(Function),
26+
Checkbox: expect.any(Object),
27+
})
28+
})
29+
})

‎packages/components/src/components/Button/__tests__/__snapshots__/index.browser.test.tsx.snap‎

Lines changed: 28 additions & 19 deletions
Large diffs are not rendered by default.
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it, mock } from 'bun:test'
2+
import { fireEvent, render } from 'bun-test-env-dom'
3+
4+
import { Button } from '../index'
5+
6+
describe('Button accessible name', () => {
7+
it('takes its name from its text, not from a forced label', () => {
8+
const { container } = render(<Button>Save changes</Button>)
9+
const button = container.querySelector('button')!
10+
11+
expect(button).not.toHaveAttribute('aria-label')
12+
expect(button.textContent).toBe('Save changes')
13+
})
14+
15+
it('keeps a label given by the caller, as an icon-only button needs', () => {
16+
const { container } = render(<Button aria-label="Close" icon={<svg />} />)
17+
18+
expect(container.querySelector('button')).toHaveAttribute(
19+
'aria-label',
20+
'Close',
21+
)
22+
})
23+
})
24+
25+
describe('Button loading', () => {
26+
it('announces the busy state and cannot be activated twice', () => {
27+
const onClick = mock()
28+
const { container } = render(
29+
<Button loading onClick={onClick}>
30+
Save
31+
</Button>,
32+
)
33+
const button = container.querySelector('button')!
34+
35+
expect(button).toHaveAttribute('aria-busy', 'true')
36+
expect(button).toBeDisabled()
37+
expect(button).toHaveAttribute('aria-disabled', 'true')
38+
fireEvent.click(button)
39+
expect(onClick).not.toHaveBeenCalled()
40+
})
41+
42+
it('tells assistive technology what it is waiting for', () => {
43+
const { container } = render(
44+
<Button loading loadingLabel="Saving">
45+
Save
46+
</Button>,
47+
)
48+
const status = container.querySelector('[role=status]')
49+
50+
expect(status?.textContent).toBe('Saving')
51+
})
52+
53+
it('says Loading by default and says nothing when it is not loading', () => {
54+
const loading = render(<Button loading>Save</Button>)
55+
expect(loading.container.querySelector('[role=status]')?.textContent).toBe(
56+
'Loading',
57+
)
58+
59+
const idle = render(<Button>Save</Button>)
60+
expect(idle.container.querySelector('[role=status]')).toBeNull()
61+
expect(idle.container.querySelector('button')).not.toHaveAttribute(
62+
'aria-busy',
63+
)
64+
expect(idle.container.querySelector('button')).not.toHaveAttribute(
65+
'aria-disabled',
66+
)
67+
})
68+
69+
it('stays actionable while loading when it is told not to disable', () => {
70+
const onClick = mock()
71+
const { container } = render(
72+
<Button disableWhileLoading={false} loading onClick={onClick}>
73+
Save
74+
</Button>,
75+
)
76+
const button = container.querySelector('button')!
77+
78+
expect(button).toHaveAttribute('aria-busy', 'true')
79+
expect(button).not.toBeDisabled()
80+
fireEvent.click(button)
81+
expect(onClick).toHaveBeenCalledTimes(1)
82+
})
83+
84+
it('stays disabled when the caller disables it, loading or not', () => {
85+
const { container } = render(
86+
<Button disabled disableWhileLoading={false}>
87+
Save
88+
</Button>,
89+
)
90+
91+
expect(container.querySelector('button')).toBeDisabled()
92+
})
93+
})

‎packages/components/src/components/Button/index.tsx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
2626
ellipsis?: boolean
2727
loading?: boolean
2828
loadingSpinner?: 'whole' | 'partial'
29+
/** Whether a loading button is disabled, so it cannot be activated twice. Defaults to true. */
30+
disableWhileLoading?: boolean
31+
/** What assistive technology announces while the button is loading */
32+
loadingLabel?: string
2933
}
3034

3135
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
@@ -44,10 +48,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
4448
disabled,
4549
loading = false,
4650
loadingSpinner = 'whole',
51+
disableWhileLoading = true,
52+
loadingLabel = 'Loading',
4753
...props
4854
},
4955
ref,
5056
) {
57+
const blocked = !!disabled || (loading && disableWhileLoading)
5158
return (
5259
<DevupButton
5360
ref={ref}
@@ -164,8 +171,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
164171
default: 'var(--inputBackground, #2E2E2E)',
165172
}[variant],
166173
}}
167-
aria-disabled={disabled}
168-
aria-label="button"
174+
aria-busy={loading || undefined}
175+
aria-disabled={blocked || undefined}
169176
bg={
170177
{
171178
primary: 'var(--primary, #8163E1)',
@@ -196,7 +203,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
196203
}[variant]
197204
}
198205
cursor="pointer"
199-
disabled={disabled}
206+
disabled={blocked}
200207
fontSize={
201208
{
202209
default: ['14px', null, null, null, '15px'],
@@ -267,6 +274,19 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
267274
>
268275
{children}
269276
</Box>
277+
{loading && (
278+
<Box
279+
as="span"
280+
clipPath="inset(50%)"
281+
h="1px"
282+
overflow="hidden"
283+
pos="absolute"
284+
role="status"
285+
w="1px"
286+
>
287+
{loadingLabel}
288+
</Box>
289+
)}
270290
</Box>
271291
</DevupButton>
272292
)

0 commit comments

Comments
 (0)