@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.
Files changed (63) hide show
  1. package/README.md +23 -6
  2. package/dist/Button.d.ts +12 -5
  3. package/dist/Button.d.ts.map +1 -1
  4. package/dist/Checkbox.d.ts +19 -5
  5. package/dist/Checkbox.d.ts.map +1 -1
  6. package/dist/IconButton.d.ts +9 -2
  7. package/dist/IconButton.d.ts.map +1 -1
  8. package/dist/PersonTrigger.d.ts +2 -1
  9. package/dist/PersonTrigger.d.ts.map +1 -1
  10. package/dist/Tabs.d.ts +4 -1
  11. package/dist/Tabs.d.ts.map +1 -1
  12. package/dist/cn.d.ts +10 -1
  13. package/dist/cn.d.ts.map +1 -1
  14. package/dist/index.js +353 -326
  15. package/dist/index.js.map +4 -4
  16. package/package.json +17 -3
  17. package/src/AppShell.stories.tsx +7 -1
  18. package/src/AppShell.tsx +1 -1
  19. package/src/Avatar.stories.tsx +3 -1
  20. package/src/Banner.stories.tsx +6 -1
  21. package/src/Banner.tsx +2 -2
  22. package/src/Breadcrumb.stories.tsx +3 -0
  23. package/src/Button.mdx +17 -3
  24. package/src/Button.stories.tsx +4 -1
  25. package/src/Button.test.tsx +119 -0
  26. package/src/Button.tsx +37 -23
  27. package/src/Checkbox.mdx +17 -6
  28. package/src/Checkbox.stories.tsx +10 -5
  29. package/src/Checkbox.test.tsx +73 -0
  30. package/src/Checkbox.tsx +52 -25
  31. package/src/Chip.stories.tsx +4 -0
  32. package/src/Chip.tsx +5 -5
  33. package/src/ChipInput.stories.tsx +4 -0
  34. package/src/DialogShell.tsx +1 -1
  35. package/src/EditableText.stories.tsx +6 -1
  36. package/src/IconButton.mdx +12 -1
  37. package/src/IconButton.stories.tsx +9 -4
  38. package/src/IconButton.test.tsx +102 -0
  39. package/src/IconButton.tsx +30 -17
  40. package/src/IdentityMenu.stories.tsx +5 -1
  41. package/src/Kbd.tsx +2 -2
  42. package/src/NavItem.stories.tsx +3 -0
  43. package/src/Person.stories.tsx +3 -0
  44. package/src/PersonTrigger.mdx +9 -1
  45. package/src/PersonTrigger.stories.tsx +3 -0
  46. package/src/PersonTrigger.test.tsx +52 -0
  47. package/src/PersonTrigger.tsx +8 -9
  48. package/src/Select.stories.tsx +7 -2
  49. package/src/Sidebar.stories.tsx +6 -0
  50. package/src/Tabs.mdx +13 -4
  51. package/src/Tabs.test.tsx +117 -0
  52. package/src/Tabs.tsx +45 -34
  53. package/src/Toast.tsx +8 -8
  54. package/src/TopBar.stories.tsx +7 -1
  55. package/src/cn.test.ts +20 -1
  56. package/src/cn.ts +18 -2
  57. package/stories/Choosing.mdx +101 -0
  58. package/stories/DesignTokens.mdx +10 -0
  59. package/stories/GettingStarted.mdx +108 -0
  60. package/stories/Introduction.mdx +36 -0
  61. package/stories/TokensPage.tsx +293 -0
  62. package/tailwind-preset.js +17 -0
  63. package/tokens.css +48 -0
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.7.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
  },
@@ -14,7 +14,13 @@ import { Sidebar } from './Sidebar'
14
14
  const meta = {
15
15
  title: 'Frame/AppShell',
16
16
  component: AppShell,
17
- parameters: { layout: 'fullscreen' },
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-[inset_0_1px_0_rgba(255,255,255,0.035)]',
54
+ 'signal:border signal:border-border-subtle signal:shadow-highlight-inset',
55
55
  )}
56
56
  >
57
57
  {banner}
@@ -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
- parameters: { controls: { disable: true } },
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) => (
@@ -4,7 +4,12 @@ import { Banner } from './Banner'
4
4
  const meta = {
5
5
  title: 'Feedback/Banner',
6
6
  component: Banner,
7
- parameters: { layout: 'fullscreen' },
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 TONE: Record<BannerTone, string> = {
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', TONE[tone], className)}>
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 a Tooltip ("Available after sign-in").
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>`, and every native prop passes through; the
53
- visible label is the accessible name, so no `aria-label` is needed.
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
 
@@ -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 a tooltip. A control someone may never use is absent, not disabled. */
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. Sizes are spelled as arbitrary values for the reason the README
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
- return (
43
- <button
52
+ const button = (
53
+ <BaseButton
44
54
  type={type}
45
- className={cn(
46
- 'inline-flex items-center justify-center gap-1 rounded-md transition-colors font-sans font-medium',
47
- size === 'default' && 'h-8 text-[14px] leading-[14px]',
48
- size === 'small' && 'h-6 text-[12px] leading-[12px]',
49
- // Extra right padding beside a leading icon, for optical balance.
50
- size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
51
- size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
52
- !disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
53
- !disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
54
- !disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
55
- !disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
56
- disabled && 'bg-bg-disabled text-text-disabled pointer-events-none',
57
- disabled && variant === 'outlined' && 'border border-border-default',
58
- className,
59
- )}
60
- disabled={disabled}
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
- </button>
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`** and the square goes inert
18
- the row's own click does the toggling, and the checkbox just shows the
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
- - It renders as a button with the `checkbox` role, so it works standalone
38
- or inside clickable rows; its click never bubbles to the row.
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
- - Space or Enter toggles it, as any button.
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
 
@@ -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
- args: { checked: false, 'aria-label': 'Example' },
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` and goes inert, so the whole row is one target rather than two
32
- * fighting ones.
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} aria-label="Row state" />
45
- <span className="text-[14px] leading-[1.4] text-text-primary">The row is the control</span>
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
+ })