@estiva-app/ui 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -6
- package/dist/Button.d.ts +12 -5
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +19 -5
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/IconButton.d.ts +9 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts +2 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Tabs.d.ts +4 -1
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/cn.d.ts +10 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/index.js +353 -326
- package/dist/index.js.map +4 -4
- package/package.json +17 -3
- package/src/AppShell.stories.tsx +7 -1
- package/src/AppShell.tsx +1 -1
- package/src/Avatar.stories.tsx +3 -1
- package/src/Banner.stories.tsx +6 -1
- package/src/Banner.tsx +2 -2
- package/src/Breadcrumb.stories.tsx +3 -0
- package/src/Button.mdx +17 -3
- package/src/Button.stories.tsx +4 -1
- package/src/Button.test.tsx +119 -0
- package/src/Button.tsx +37 -23
- package/src/Checkbox.mdx +17 -6
- package/src/Checkbox.stories.tsx +10 -5
- package/src/Checkbox.test.tsx +73 -0
- package/src/Checkbox.tsx +52 -25
- package/src/Chip.stories.tsx +4 -0
- package/src/Chip.tsx +5 -5
- package/src/ChipInput.stories.tsx +4 -0
- package/src/DialogShell.tsx +1 -1
- package/src/EditableText.stories.tsx +6 -1
- package/src/IconButton.mdx +12 -1
- package/src/IconButton.stories.tsx +9 -4
- package/src/IconButton.test.tsx +102 -0
- package/src/IconButton.tsx +30 -17
- package/src/IdentityMenu.stories.tsx +5 -1
- package/src/Kbd.tsx +2 -2
- package/src/NavItem.stories.tsx +3 -0
- package/src/Person.stories.tsx +3 -0
- package/src/PersonTrigger.mdx +9 -1
- package/src/PersonTrigger.stories.tsx +3 -0
- package/src/PersonTrigger.test.tsx +52 -0
- package/src/PersonTrigger.tsx +8 -9
- package/src/Select.stories.tsx +7 -2
- package/src/Sidebar.stories.tsx +6 -0
- package/src/Tabs.mdx +13 -4
- package/src/Tabs.test.tsx +117 -0
- package/src/Tabs.tsx +45 -34
- package/src/Toast.tsx +8 -8
- package/src/TopBar.stories.tsx +7 -1
- package/src/cn.test.ts +20 -1
- package/src/cn.ts +18 -2
- package/stories/Choosing.mdx +101 -0
- package/stories/DesignTokens.mdx +10 -0
- package/stories/GettingStarted.mdx +108 -0
- package/stories/Introduction.mdx +36 -0
- package/stories/TokensPage.tsx +293 -0
- package/tailwind-preset.js +17 -0
- package/tokens.css +48 -0
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estiva-app/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"files": [
|
|
8
8
|
"dist",
|
|
9
9
|
"src",
|
|
10
|
+
"stories",
|
|
10
11
|
"tailwind-preset.js",
|
|
11
12
|
"tokens.css",
|
|
12
13
|
"base.css",
|
|
@@ -27,9 +28,11 @@
|
|
|
27
28
|
"build": "node build.mjs && tsc -p tsconfig.build.json",
|
|
28
29
|
"storybook": "storybook dev -p 6008 --no-open",
|
|
29
30
|
"build-storybook": "storybook build",
|
|
30
|
-
"test": "vitest run",
|
|
31
|
-
"test:watch": "vitest",
|
|
31
|
+
"test": "vitest run --project unit",
|
|
32
|
+
"test:watch": "vitest --project unit",
|
|
33
|
+
"test:a11y": "vitest run --project storybook-signal --project storybook-ship",
|
|
32
34
|
"typecheck": "tsc --noEmit",
|
|
35
|
+
"lint": "eslint .",
|
|
33
36
|
"prepublishOnly": "npm run build"
|
|
34
37
|
},
|
|
35
38
|
"peerDependencies": {
|
|
@@ -39,7 +42,10 @@
|
|
|
39
42
|
"tailwindcss": "^3.4.0"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^10.0.1",
|
|
46
|
+
"@storybook/addon-a11y": "^10.6.0",
|
|
42
47
|
"@storybook/addon-docs": "^10.5.10",
|
|
48
|
+
"@storybook/addon-vitest": "^10.6.0",
|
|
43
49
|
"@storybook/react-vite": "^10.5.10",
|
|
44
50
|
"@tabler/icons-react": "^3.41.1",
|
|
45
51
|
"@testing-library/dom": "^10.4.1",
|
|
@@ -49,9 +55,15 @@
|
|
|
49
55
|
"@types/react": "^19.2.14",
|
|
50
56
|
"@types/react-dom": "^19.2.3",
|
|
51
57
|
"@vitejs/plugin-react": "^6.0.1",
|
|
58
|
+
"@vitest/browser": "^4.1.11",
|
|
59
|
+
"@vitest/browser-playwright": "^4.1.11",
|
|
52
60
|
"autoprefixer": "^10.4.27",
|
|
53
61
|
"esbuild": "^0.28.0",
|
|
62
|
+
"eslint": "^10.10.0",
|
|
63
|
+
"eslint-plugin-better-tailwindcss": "^4.7.0",
|
|
64
|
+
"globals": "^17.12.0",
|
|
54
65
|
"jsdom": "^30.0.1",
|
|
66
|
+
"playwright": "^1.63.0",
|
|
55
67
|
"postcss": "^8.5.8",
|
|
56
68
|
"react": "^19.2.4",
|
|
57
69
|
"react-dom": "^19.2.4",
|
|
@@ -59,10 +71,12 @@
|
|
|
59
71
|
"storybook": "^10.5.10",
|
|
60
72
|
"tailwindcss": "^3.4.19",
|
|
61
73
|
"typescript": "~5.9.3",
|
|
74
|
+
"typescript-eslint": "^8.69.0",
|
|
62
75
|
"vite": "^8.0.1",
|
|
63
76
|
"vitest": "^4.1.11"
|
|
64
77
|
},
|
|
65
78
|
"dependencies": {
|
|
79
|
+
"@base-ui/react": "^1.8.0",
|
|
66
80
|
"clsx": "^2.1.1",
|
|
67
81
|
"tailwind-merge": "^3.5.0"
|
|
68
82
|
},
|
package/src/AppShell.stories.tsx
CHANGED
|
@@ -14,7 +14,13 @@ import { Sidebar } from './Sidebar'
|
|
|
14
14
|
const meta = {
|
|
15
15
|
title: 'Frame/AppShell',
|
|
16
16
|
component: AppShell,
|
|
17
|
-
parameters: {
|
|
17
|
+
parameters: {
|
|
18
|
+
layout: 'fullscreen',
|
|
19
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
20
|
+
// the count chip is muted text, 3.06:1 on the active row in signal, and the info
|
|
21
|
+
// banner reads 3.99:1 on its wash in ship (AA 4.5:1).
|
|
22
|
+
a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
|
|
23
|
+
},
|
|
18
24
|
args: { nav: null, children: null },
|
|
19
25
|
argTypes: {
|
|
20
26
|
variant: { control: 'inline-radio', options: ['solid', 'floating'] },
|
package/src/AppShell.tsx
CHANGED
|
@@ -51,7 +51,7 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
|
|
|
51
51
|
<div
|
|
52
52
|
className={cn(
|
|
53
53
|
'flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-bg-surface',
|
|
54
|
-
'signal:border signal:border-border-subtle signal:shadow-
|
|
54
|
+
'signal:border signal:border-border-subtle signal:shadow-highlight-inset',
|
|
55
55
|
)}
|
|
56
56
|
>
|
|
57
57
|
{banner}
|
package/src/Avatar.stories.tsx
CHANGED
|
@@ -29,7 +29,9 @@ export const BrokenPicture: Story = { args: { src: 'http://localhost:1/nothing-h
|
|
|
29
29
|
|
|
30
30
|
/** Peek's size scale, plus the smaller ones Ship uses in rows and menus. */
|
|
31
31
|
export const Sizes: Story = {
|
|
32
|
-
|
|
32
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
33
|
+
// the size captions are muted text, 3.94:1 on --bg-base in signal (AA 4.5:1).
|
|
34
|
+
parameters: { controls: { disable: true }, a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
33
35
|
render: (args) => (
|
|
34
36
|
<div className="flex items-end gap-3">
|
|
35
37
|
{[16, 20, 22, 24, 28, 32, 36].map((size) => (
|
package/src/Banner.stories.tsx
CHANGED
|
@@ -4,7 +4,12 @@ import { Banner } from './Banner'
|
|
|
4
4
|
const meta = {
|
|
5
5
|
title: 'Feedback/Banner',
|
|
6
6
|
component: Banner,
|
|
7
|
-
parameters: {
|
|
7
|
+
parameters: {
|
|
8
|
+
layout: 'fullscreen',
|
|
9
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
10
|
+
// the info tone reads 3.99:1 on its wash in ship (AA 4.5:1).
|
|
11
|
+
a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
|
|
12
|
+
},
|
|
8
13
|
args: { tone: 'ok', children: 'Public key copied.' },
|
|
9
14
|
argTypes: { tone: { control: 'inline-radio', options: ['ok', 'error', 'info', 'warning'] } },
|
|
10
15
|
} satisfies Meta<typeof Banner>
|
package/src/Banner.tsx
CHANGED
|
@@ -19,7 +19,7 @@ export interface BannerProps {
|
|
|
19
19
|
className?: string
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const
|
|
22
|
+
const TONE_STYLES: Record<BannerTone, string> = {
|
|
23
23
|
ok: 'bg-success-muted text-success-default',
|
|
24
24
|
error: 'bg-error-muted text-error-default',
|
|
25
25
|
info: 'bg-info-muted text-info-default',
|
|
@@ -28,7 +28,7 @@ const TONE: Record<BannerTone, string> = {
|
|
|
28
28
|
|
|
29
29
|
export function Banner({ tone, children, className }: BannerProps) {
|
|
30
30
|
return (
|
|
31
|
-
<div role={tone === 'error' ? 'alert' : 'status'} className={cn('px-4 py-2 text-body-2',
|
|
31
|
+
<div role={tone === 'error' ? 'alert' : 'status'} className={cn('px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
|
|
32
32
|
{children}
|
|
33
33
|
</div>
|
|
34
34
|
)
|
|
@@ -5,6 +5,9 @@ import { Breadcrumb } from './Breadcrumb'
|
|
|
5
5
|
const meta = {
|
|
6
6
|
title: 'Navigation/Breadcrumb',
|
|
7
7
|
component: Breadcrumb,
|
|
8
|
+
// axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
|
|
9
|
+
// the current item is muted text, 3.94:1 on --bg-base in signal (AA 4.5:1).
|
|
10
|
+
parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
|
|
8
11
|
args: {
|
|
9
12
|
items: [
|
|
10
13
|
{ label: 'Documents', href: '#' },
|
package/src/Button.mdx
CHANGED
|
@@ -32,7 +32,7 @@ The push-button for an action: a verb, clicked once — "Create", "Save",
|
|
|
32
32
|
- Choosing a value → **Select**. Acting from a popover list → **MenuItem**.
|
|
33
33
|
- A control the person may never be allowed to use is **absent, not
|
|
34
34
|
disabled**. `disabled` is for *momentarily* unavailable — and it says why,
|
|
35
|
-
with
|
|
35
|
+
with `disabledReason` ("Available after sign-in").
|
|
36
36
|
|
|
37
37
|
## How
|
|
38
38
|
|
|
@@ -49,8 +49,22 @@ import { IconPlus } from '@tabler/icons-react'
|
|
|
49
49
|
button balances its own padding around it.
|
|
50
50
|
- `type` defaults to `"button"` — inside a form it submits only when you
|
|
51
51
|
say `type="submit"`.
|
|
52
|
-
- It is a native `<button
|
|
53
|
-
visible label is the accessible name, so no
|
|
52
|
+
- It is a native `<button>` on Base UI's Button, and every native prop
|
|
53
|
+
passes through; the visible label is the accessible name, so no
|
|
54
|
+
`aria-label` is needed.
|
|
55
|
+
- `disabledReason="Sign in first"` disables it, keeps it reachable by
|
|
56
|
+
Tab, and shows the reason as a tooltip on hover. Say why, every time;
|
|
57
|
+
a bare `disabled` is for the moment between a click and its answer. The
|
|
58
|
+
reason shows on hover only for now; it will also show on keyboard focus
|
|
59
|
+
when Tooltip moves onto Base UI (stage 4). With a reason the button sits
|
|
60
|
+
in the tooltip's `inline-flex` wrapper.
|
|
61
|
+
|
|
62
|
+
## Keys
|
|
63
|
+
|
|
64
|
+
| Key | Does |
|
|
65
|
+
|---|---|
|
|
66
|
+
| Tab | Onto the button, also when it is disabled with a reason; never when it is plain `disabled`. |
|
|
67
|
+
| Space / Enter | The action. Nothing while disabled. |
|
|
54
68
|
|
|
55
69
|
## Props
|
|
56
70
|
|
package/src/Button.stories.tsx
CHANGED
|
@@ -23,9 +23,12 @@ export const Muted: Story = { args: { variant: 'muted' } }
|
|
|
23
23
|
export const Destructive: Story = { args: { variant: 'destructive', children: 'Delete project' } }
|
|
24
24
|
export const Small: Story = { args: { variant: 'primary', size: 'small' } }
|
|
25
25
|
export const WithLeadingIcon: Story = { args: { variant: 'primary', leadingIcon: <IconPlus stroke={1.5} className="size-4" /> } }
|
|
26
|
-
/** Disabled is for a control that is momentarily unavailable — say why, with
|
|
26
|
+
/** Disabled is for a control that is momentarily unavailable — say why, with `disabledReason`. A control someone may never use is absent, not disabled. */
|
|
27
27
|
export const Disabled: Story = { args: { variant: 'primary', disabled: true } }
|
|
28
28
|
|
|
29
|
+
/** Disabled with its reason: hover to read it; Tab still reaches the button. */
|
|
30
|
+
export const WithAReason: Story = { args: { variant: 'primary', disabledReason: 'Sign in to add items' } }
|
|
31
|
+
|
|
29
32
|
/** Every variant × size × icon × disabled combination on one canvas. */
|
|
30
33
|
export const AllVariants: Story = {
|
|
31
34
|
parameters: { controls: { disable: true } },
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Button page claims, pinned: a native button that does not submit
|
|
4
|
+
* unless told to, `disabled` as before, and `disabledReason` — disabled,
|
|
5
|
+
* still reachable by keyboard, the reason shown on hover (stage 2 of the
|
|
6
|
+
* migration).
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
9
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
10
|
+
import userEvent from '@testing-library/user-event'
|
|
11
|
+
import type { FormEvent } from 'react'
|
|
12
|
+
import { Button } from './Button'
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
describe('Button', () => {
|
|
17
|
+
it('is a native button that does not submit a form unless asked', async () => {
|
|
18
|
+
const user = userEvent.setup()
|
|
19
|
+
const onSubmit = vi.fn((e: FormEvent<HTMLFormElement>) => e.preventDefault())
|
|
20
|
+
const onClick = vi.fn()
|
|
21
|
+
render(
|
|
22
|
+
<form onSubmit={onSubmit}>
|
|
23
|
+
<Button onClick={onClick}>Plain</Button>
|
|
24
|
+
<Button type="submit">Send</Button>
|
|
25
|
+
</form>,
|
|
26
|
+
)
|
|
27
|
+
const plain = screen.getByRole('button', { name: 'Plain' })
|
|
28
|
+
expect(plain.tagName).toBe('BUTTON')
|
|
29
|
+
expect(plain.getAttribute('type')).toBe('button')
|
|
30
|
+
await user.click(plain)
|
|
31
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
32
|
+
expect(onSubmit).not.toHaveBeenCalled()
|
|
33
|
+
await user.click(screen.getByRole('button', { name: 'Send' }))
|
|
34
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('renders the leading icon before the label', () => {
|
|
38
|
+
render(<Button leadingIcon={<svg data-testid="icon" />}>Label</Button>)
|
|
39
|
+
const button = screen.getByRole('button', { name: 'Label' })
|
|
40
|
+
expect(button.firstElementChild).toBe(screen.getByTestId('icon'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('disabled: a real disabled button, out of the Tab order, that ignores clicks', async () => {
|
|
44
|
+
const user = userEvent.setup()
|
|
45
|
+
const onClick = vi.fn()
|
|
46
|
+
render(
|
|
47
|
+
<>
|
|
48
|
+
<Button disabled onClick={onClick}>
|
|
49
|
+
Held
|
|
50
|
+
</Button>
|
|
51
|
+
<Button>After</Button>
|
|
52
|
+
</>,
|
|
53
|
+
)
|
|
54
|
+
const held = screen.getByRole('button', { name: 'Held' })
|
|
55
|
+
expect(held.hasAttribute('disabled')).toBe(true)
|
|
56
|
+
await user.click(held)
|
|
57
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
58
|
+
await user.tab()
|
|
59
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'After' }))
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('disabledReason: disabled, still reachable by Tab, the reason on hover', async () => {
|
|
63
|
+
const user = userEvent.setup()
|
|
64
|
+
const onClick = vi.fn()
|
|
65
|
+
render(
|
|
66
|
+
<Button disabledReason="Sign in first" onClick={onClick}>
|
|
67
|
+
Add
|
|
68
|
+
</Button>,
|
|
69
|
+
)
|
|
70
|
+
const button = screen.getByRole('button', { name: 'Add' })
|
|
71
|
+
expect(button.getAttribute('aria-disabled')).toBe('true')
|
|
72
|
+
expect(button.hasAttribute('disabled')).toBe(false)
|
|
73
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
74
|
+
await user.hover(button)
|
|
75
|
+
expect(screen.getByRole('tooltip').textContent).toBe('Sign in first')
|
|
76
|
+
await user.unhover(button)
|
|
77
|
+
expect(screen.queryByRole('tooltip')).toBeNull()
|
|
78
|
+
await user.click(button)
|
|
79
|
+
expect(onClick).not.toHaveBeenCalled()
|
|
80
|
+
await user.tab()
|
|
81
|
+
expect(document.activeElement).toBe(button)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('Space and Enter are the action, and nothing while disabled with a reason', async () => {
|
|
85
|
+
const user = userEvent.setup()
|
|
86
|
+
const onClick = vi.fn()
|
|
87
|
+
const { unmount } = render(<Button onClick={onClick}>Go</Button>)
|
|
88
|
+
await user.tab()
|
|
89
|
+
await user.keyboard('{Enter}')
|
|
90
|
+
await user.keyboard(' ')
|
|
91
|
+
expect(onClick).toHaveBeenCalledTimes(2)
|
|
92
|
+
unmount()
|
|
93
|
+
|
|
94
|
+
const held = vi.fn()
|
|
95
|
+
render(
|
|
96
|
+
<Button disabledReason="Not now" onClick={held}>
|
|
97
|
+
Go
|
|
98
|
+
</Button>,
|
|
99
|
+
)
|
|
100
|
+
await user.tab()
|
|
101
|
+
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Go' }))
|
|
102
|
+
await user.keyboard('{Enter}')
|
|
103
|
+
await user.keyboard(' ')
|
|
104
|
+
expect(held).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('passes native props through', () => {
|
|
108
|
+
render(
|
|
109
|
+
<Button form="f1" aria-pressed="true" data-x="y" className="mt-2">
|
|
110
|
+
Label
|
|
111
|
+
</Button>,
|
|
112
|
+
)
|
|
113
|
+
const button = screen.getByRole('button', { name: 'Label' })
|
|
114
|
+
expect(button.getAttribute('form')).toBe('f1')
|
|
115
|
+
expect(button.getAttribute('aria-pressed')).toBe('true')
|
|
116
|
+
expect(button.getAttribute('data-x')).toBe('y')
|
|
117
|
+
expect(button.classList.contains('mt-2')).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
})
|
package/src/Button.tsx
CHANGED
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { Button as BaseButton } from '@base-ui/react/button'
|
|
2
3
|
import { cn } from './cn'
|
|
4
|
+
import { WithTooltip } from './Tooltip'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Peek's Button (2026-08-28), verbatim, plus what Ship added and Peek should
|
|
6
8
|
* adopt: a `destructive` variant — the muted button in the error colour, for
|
|
7
9
|
* "Delete project" and its kind; a destructive action is a button like any
|
|
8
10
|
* other, not a dotted link — and `type="button"` by default, so a button
|
|
9
|
-
* inside a form submits it only when asked to.
|
|
11
|
+
* inside a form submits it only when asked to. On Base UI's Button since
|
|
12
|
+
* stage 2 of the migration (2026-09-07).
|
|
10
13
|
*
|
|
11
14
|
* Three variants and two sizes: 32px default / 24px small, 6px radius, 500
|
|
12
|
-
* weight.
|
|
13
|
-
* records: tailwind-merge drops a custom `text-{size}` that is followed by a
|
|
14
|
-
* `text-{colour}`.
|
|
15
|
+
* weight. The sizes are the `btn-default` and `btn-small` type tokens.
|
|
15
16
|
*
|
|
16
17
|
* The primary reads in `text-inverse` on the accent — a theme decides what
|
|
17
18
|
* that is (dark on Peek's light accents, light on Ship's dark one). Under
|
|
18
19
|
* Signal it is also semibold, as Peek has it.
|
|
20
|
+
*
|
|
21
|
+
* `disabledReason` is the package's own rule, "only offer actions that can
|
|
22
|
+
* succeed", done once: the button is disabled, stays reachable by keyboard
|
|
23
|
+
* (Base UI's `focusableWhenDisabled`), and shows the reason as a tooltip on
|
|
24
|
+
* hover. Ship wrote that wrapper by hand six times.
|
|
19
25
|
*/
|
|
20
26
|
export type ButtonVariant = 'primary' | 'outlined' | 'muted' | 'destructive'
|
|
21
27
|
export type ButtonSize = 'default' | 'small'
|
|
@@ -25,6 +31,9 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
25
31
|
size?: ButtonSize
|
|
26
32
|
/** 16px, stroke 1.5 on the default size; 14px on small. */
|
|
27
33
|
leadingIcon?: ReactNode
|
|
34
|
+
/** Why the action cannot succeed right now. Disables the button, keeps it
|
|
35
|
+
* reachable by keyboard, and shows the reason as a tooltip on hover. */
|
|
36
|
+
disabledReason?: string
|
|
28
37
|
children: ReactNode
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -35,33 +44,38 @@ export function Button({
|
|
|
35
44
|
className,
|
|
36
45
|
children,
|
|
37
46
|
disabled,
|
|
47
|
+
disabledReason,
|
|
38
48
|
type = 'button',
|
|
39
49
|
...props
|
|
40
50
|
}: ButtonProps) {
|
|
41
51
|
const hasLeadingIcon = !!leadingIcon
|
|
42
|
-
|
|
43
|
-
<
|
|
52
|
+
const button = (
|
|
53
|
+
<BaseButton
|
|
44
54
|
type={type}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
disabled={disabled || !!disabledReason}
|
|
56
|
+
focusableWhenDisabled={!!disabledReason}
|
|
57
|
+
className={(state) =>
|
|
58
|
+
cn(
|
|
59
|
+
'inline-flex items-center justify-center gap-1 rounded-md transition-colors font-sans font-medium',
|
|
60
|
+
size === 'default' && 'h-8 text-btn-default',
|
|
61
|
+
size === 'small' && 'h-6 text-btn-small',
|
|
62
|
+
// Extra right padding beside a leading icon, for optical balance.
|
|
63
|
+
size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
|
|
64
|
+
size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
|
|
65
|
+
!state.disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
|
|
66
|
+
!state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
|
|
67
|
+
!state.disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
|
|
68
|
+
!state.disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
|
|
69
|
+
state.disabled && 'bg-bg-disabled text-text-disabled pointer-events-none',
|
|
70
|
+
state.disabled && variant === 'outlined' && 'border border-border-default',
|
|
71
|
+
className,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
61
74
|
{...props}
|
|
62
75
|
>
|
|
63
76
|
{leadingIcon}
|
|
64
77
|
{children}
|
|
65
|
-
</
|
|
78
|
+
</BaseButton>
|
|
66
79
|
)
|
|
80
|
+
return disabledReason ? <WithTooltip label={disabledReason}>{button}</WithTooltip> : button
|
|
67
81
|
}
|
package/src/Checkbox.mdx
CHANGED
|
@@ -14,9 +14,10 @@ the parent owns the state.
|
|
|
14
14
|
|
|
15
15
|
- A yes/no the person sets: an option in a form, an include/exclude in a
|
|
16
16
|
list.
|
|
17
|
-
- Inside a clickable row: pass **no `onChange
|
|
18
|
-
|
|
19
|
-
state
|
|
17
|
+
- Inside a clickable row: pass **no `onChange`**. The square then only
|
|
18
|
+
draws the state: it is hidden from assistive technology and takes no
|
|
19
|
+
focus, so the row is one control. The row says the state itself —
|
|
20
|
+
`aria-pressed` on a button row, `aria-selected` on an option in a list.
|
|
20
21
|
|
|
21
22
|
<Canvas of={CheckboxStories.InsideARow} />
|
|
22
23
|
|
|
@@ -34,10 +35,20 @@ import { Checkbox } from '@estiva-app/ui'
|
|
|
34
35
|
<Checkbox checked={done} onChange={setDone} aria-label="Mark as done" />
|
|
35
36
|
```
|
|
36
37
|
|
|
37
|
-
-
|
|
38
|
-
|
|
38
|
+
- Built on Base UI Checkbox, as Base UI renders it: a `<span>` with
|
|
39
|
+
`role="checkbox"` and `aria-checked`, and a hidden `<input>` beside it
|
|
40
|
+
for forms. Its click never reaches the row around it.
|
|
39
41
|
- Give it an `aria-label` unless visible text right beside it names it.
|
|
40
|
-
-
|
|
42
|
+
- It does not move when it toggles: the tick is always in the box, hidden
|
|
43
|
+
when unchecked, so both states hang on a line of text the same way.
|
|
44
|
+
|
|
45
|
+
## Keys
|
|
46
|
+
|
|
47
|
+
| Key | Does |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Tab | Onto the box. |
|
|
50
|
+
| Space | Toggles it. |
|
|
51
|
+
| Enter | Nothing. As on a native checkbox, Enter is the form's key. |
|
|
41
52
|
|
|
42
53
|
## Props
|
|
43
54
|
|
package/src/Checkbox.stories.tsx
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { fn } from 'storybook/test'
|
|
2
3
|
import { useState } from 'react'
|
|
3
4
|
import { Checkbox } from './Checkbox'
|
|
4
5
|
|
|
5
6
|
const meta = {
|
|
6
7
|
title: 'Inputs/Checkbox',
|
|
7
8
|
component: Checkbox,
|
|
8
|
-
|
|
9
|
+
// `onChange` makes it the control; without one it is the picture of a
|
|
10
|
+
// state (the "inside a row" story), and these stories are about the control.
|
|
11
|
+
args: { checked: false, 'aria-label': 'Example', onChange: fn() },
|
|
9
12
|
argTypes: { onChange: { control: false } },
|
|
10
13
|
} satisfies Meta<typeof Checkbox>
|
|
11
14
|
|
|
@@ -28,8 +31,9 @@ export const Toggles: Story = {
|
|
|
28
31
|
|
|
29
32
|
/**
|
|
30
33
|
* Inside a clickable row the row owns the toggle: the checkbox gets no
|
|
31
|
-
* `onChange
|
|
32
|
-
*
|
|
34
|
+
* `onChange`, draws the state, and is hidden from assistive technology, so
|
|
35
|
+
* the whole row is one control. The row says the state itself — here
|
|
36
|
+
* `aria-pressed`, on an option in a list `aria-selected`.
|
|
33
37
|
*/
|
|
34
38
|
export const InsideARow: Story = {
|
|
35
39
|
parameters: { controls: { disable: true } },
|
|
@@ -38,11 +42,12 @@ export const InsideARow: Story = {
|
|
|
38
42
|
return (
|
|
39
43
|
<button
|
|
40
44
|
type="button"
|
|
45
|
+
aria-pressed={checked}
|
|
41
46
|
onClick={() => setChecked((v) => !v)}
|
|
42
47
|
className="flex w-64 items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-bg-hover"
|
|
43
48
|
>
|
|
44
|
-
<Checkbox checked={checked}
|
|
45
|
-
<span className="text-
|
|
49
|
+
<Checkbox checked={checked} />
|
|
50
|
+
<span className="text-body-2 text-text-primary">The row is the control</span>
|
|
46
51
|
</button>
|
|
47
52
|
)
|
|
48
53
|
},
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* What the Checkbox page claims, pinned: the keys, the click that never
|
|
4
|
+
* reaches the row, and the second rendering — no `onChange`, a picture of
|
|
5
|
+
* the state that is not a control. The last one is what clears axe's
|
|
6
|
+
* `nested-interactive` on the "inside a row" story (stage 1 of the
|
|
7
|
+
* migration).
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
11
|
+
import userEvent from '@testing-library/user-event'
|
|
12
|
+
import { Checkbox } from './Checkbox'
|
|
13
|
+
|
|
14
|
+
afterEach(cleanup)
|
|
15
|
+
|
|
16
|
+
describe('Checkbox', () => {
|
|
17
|
+
it('is a checkbox that Space toggles, and Enter does not', async () => {
|
|
18
|
+
const user = userEvent.setup()
|
|
19
|
+
const onChange = vi.fn()
|
|
20
|
+
render(<Checkbox checked={false} onChange={onChange} aria-label="Done" />)
|
|
21
|
+
const box = screen.getByRole('checkbox', { name: 'Done' })
|
|
22
|
+
expect(box.getAttribute('aria-checked')).toBe('false')
|
|
23
|
+
|
|
24
|
+
await user.tab()
|
|
25
|
+
expect(document.activeElement).toBe(box)
|
|
26
|
+
await user.keyboard('{Enter}')
|
|
27
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
28
|
+
await user.keyboard(' ')
|
|
29
|
+
expect(onChange).toHaveBeenCalledTimes(1)
|
|
30
|
+
expect(onChange).toHaveBeenCalledWith(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('toggles on click, and the click never reaches the row', async () => {
|
|
34
|
+
const user = userEvent.setup()
|
|
35
|
+
const onChange = vi.fn()
|
|
36
|
+
const onRow = vi.fn()
|
|
37
|
+
render(
|
|
38
|
+
<div onClick={onRow}>
|
|
39
|
+
<Checkbox checked onChange={onChange} aria-label="Done" />
|
|
40
|
+
</div>,
|
|
41
|
+
)
|
|
42
|
+
await user.click(screen.getByRole('checkbox', { name: 'Done' }))
|
|
43
|
+
expect(onChange).toHaveBeenCalledWith(false)
|
|
44
|
+
expect(onRow).not.toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does nothing when disabled', async () => {
|
|
48
|
+
const user = userEvent.setup()
|
|
49
|
+
const onChange = vi.fn()
|
|
50
|
+
render(<Checkbox checked={false} disabled onChange={onChange} aria-label="Done" />)
|
|
51
|
+
const box = screen.getByRole('checkbox', { name: 'Done' })
|
|
52
|
+
expect(box.getAttribute('aria-disabled')).toBe('true')
|
|
53
|
+
await user.click(box)
|
|
54
|
+
expect(onChange).not.toHaveBeenCalled()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('with no onChange is a picture of the state, not a control: the row gets the click', async () => {
|
|
58
|
+
const user = userEvent.setup()
|
|
59
|
+
const onRow = vi.fn()
|
|
60
|
+
const { container } = render(
|
|
61
|
+
<button type="button" aria-pressed onClick={onRow}>
|
|
62
|
+
<Checkbox checked aria-label="Ignored" />
|
|
63
|
+
The row is the control
|
|
64
|
+
</button>,
|
|
65
|
+
)
|
|
66
|
+
expect(screen.queryByRole('checkbox')).toBeNull()
|
|
67
|
+
const square = container.querySelector('[aria-hidden="true"]')
|
|
68
|
+
expect(square).not.toBeNull()
|
|
69
|
+
expect(container.querySelectorAll('input, [tabindex], [role="checkbox"]')).toHaveLength(0)
|
|
70
|
+
await user.click(square as HTMLElement)
|
|
71
|
+
expect(onRow).toHaveBeenCalledTimes(1)
|
|
72
|
+
})
|
|
73
|
+
})
|