@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/src/Checkbox.tsx CHANGED
@@ -1,14 +1,29 @@
1
+ import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'
1
2
  import { IconCheck } from '@tabler/icons-react'
2
3
  import { cn } from './cn'
3
4
 
4
5
  /**
5
6
  * A 16px square that fills with the accent when checked — Peek's Checkbox
6
- * (2026-09-01), verbatim.
7
+ * (2026-09-01), verbatim; on Base UI Checkbox since stage 1 of the migration
8
+ * (2026-09-06).
7
9
  *
8
- * Controlled only; parents own the state. Rendered as a button with the
9
- * checkbox role so it works standalone or inside clickable rows: a caller
10
- * that toggles on the row's own click passes no `onChange`, and the square
11
- * goes inert rather than stealing the click.
10
+ * Controlled only; parents own the state. Two renderings, one class list:
11
+ *
12
+ * - With `onChange` it is the control, as Base UI renders it: a
13
+ * `<span role="checkbox">` with a hidden `<input>` beside it. Space
14
+ * toggles, Enter does not, and its click never reaches the row around it.
15
+ * - Without `onChange` it is a picture of the state, for a row that toggles
16
+ * on its own click. It is hidden from assistive technology and takes no
17
+ * focus, so the row is one control and says the state itself
18
+ * (`aria-pressed`, or `aria-selected` on an option). Two focusable controls
19
+ * nested in one another is what axe's `nested-interactive` rule rejects.
20
+ *
21
+ * The tick is always in the box, hidden when unchecked. A box that is empty
22
+ * in one state and holds an icon in the other hangs on a line of text
23
+ * differently in each, and moved 1px on every click (Katerina, 2026-09-07:
24
+ * "it should be fixed"). With the tick always there, Base UI's `<span>` and
25
+ * the `<button>` this used to be land on the same pixel, measured. In a flex
26
+ * row nothing ever moved.
12
27
  */
13
28
  export interface CheckboxProps {
14
29
  checked: boolean
@@ -18,29 +33,41 @@ export interface CheckboxProps {
18
33
  className?: string
19
34
  }
20
35
 
36
+ function squareClasses(checked: boolean, disabled: boolean, interactive: boolean, className?: string) {
37
+ return cn(
38
+ 'inline-flex items-center justify-center size-4 shrink-0 rounded-[4px] border transition-colors',
39
+ checked ? 'bg-accent-primary border-accent-primary text-text-inverse' : 'bg-transparent border-border-strong hover:border-text-muted',
40
+ disabled && 'opacity-50 pointer-events-none',
41
+ interactive ? 'cursor-pointer' : 'pointer-events-none',
42
+ className,
43
+ )
44
+ }
45
+
46
+ /** `flex`, so the icon is a flex item and not an inline box with a line height of its own. */
47
+ const tickClasses = (checked: boolean) => cn('flex', !checked && 'invisible')
48
+
21
49
  export function Checkbox({ checked, onChange, disabled = false, className, ...aria }: CheckboxProps) {
50
+ if (!onChange) {
51
+ return (
52
+ <span aria-hidden="true" className={squareClasses(checked, disabled, false, className)}>
53
+ <span className={tickClasses(checked)}>
54
+ <IconCheck size={12} stroke={3} />
55
+ </span>
56
+ </span>
57
+ )
58
+ }
22
59
  return (
23
- <button
24
- type="button"
25
- role="checkbox"
26
- aria-checked={checked}
27
- aria-label={aria['aria-label']}
60
+ <BaseCheckbox.Root
61
+ checked={checked}
28
62
  disabled={disabled}
29
- onClick={(e) => {
30
- e.stopPropagation()
31
- onChange?.(!checked)
32
- }}
33
- className={cn(
34
- 'inline-flex items-center justify-center size-4 shrink-0 rounded-[4px] border transition-colors',
35
- checked
36
- ? 'bg-accent-primary border-accent-primary text-text-inverse'
37
- : 'bg-transparent border-border-strong hover:border-text-muted',
38
- disabled && 'opacity-50 pointer-events-none',
39
- onChange ? 'cursor-pointer' : 'pointer-events-none',
40
- className,
41
- )}
63
+ aria-label={aria['aria-label']}
64
+ onCheckedChange={(next) => onChange(next)}
65
+ onClick={(e) => e.stopPropagation()}
66
+ className={(state) => squareClasses(state.checked, state.disabled, true, className)}
42
67
  >
43
- {checked && <IconCheck size={12} stroke={3} />}
44
- </button>
68
+ <BaseCheckbox.Indicator keepMounted className={(state) => tickClasses(state.checked)}>
69
+ <IconCheck size={12} stroke={3} />
70
+ </BaseCheckbox.Indicator>
71
+ </BaseCheckbox.Root>
45
72
  )
46
73
  }
@@ -5,6 +5,10 @@ import { Chip } from './Chip'
5
5
  const meta = {
6
6
  title: 'Primitives/Chip',
7
7
  component: Chip,
8
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
9
+ // in ship the brand chip reads 2.70:1 and the info chip 3.99:1 on their washes
10
+ // (AA 4.5:1).
11
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
8
12
  args: { type: 'neutral', label: 'Label' },
9
13
  argTypes: {
10
14
  type: { control: 'inline-radio', options: ['neutral', 'brand', 'info', 'warning', 'success', 'error'] },
package/src/Chip.tsx CHANGED
@@ -23,11 +23,11 @@ export interface ChipProps {
23
23
 
24
24
  const typeStyles: Record<ChipType, string> = {
25
25
  neutral: 'bg-bg-inset text-text-primary',
26
- brand: 'bg-accent-muted text-accent-primary signal:border signal:border-[rgba(86,200,255,0.3)]',
27
- info: 'bg-info-muted text-info-default signal:border signal:border-[rgba(86,200,255,0.3)]',
28
- warning: 'bg-warning-muted text-warning-default signal:border signal:border-[rgba(255,176,32,0.3)] signal:shadow-[shadow:0_0_5px_rgba(255,176,32,0.4)]',
29
- success: 'bg-success-muted text-success-default signal:border signal:border-[rgba(63,222,140,0.3)]',
30
- error: 'bg-error-muted text-error-default signal:border signal:border-[rgba(255,107,107,0.3)]',
26
+ brand: 'bg-accent-muted text-accent-primary signal:border signal:border-accent-outline',
27
+ info: 'bg-info-muted text-info-default signal:border signal:border-info-outline',
28
+ warning: 'bg-warning-muted text-warning-default signal:border signal:border-warning-outline signal:shadow-glow-warning',
29
+ success: 'bg-success-muted text-success-default signal:border signal:border-success-outline',
30
+ error: 'bg-error-muted text-error-default signal:border signal:border-error-outline',
31
31
  }
32
32
 
33
33
  export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
@@ -29,6 +29,10 @@ const meta = {
29
29
  title: 'Inputs/ChipInput',
30
30
  component: ChipInput,
31
31
  parameters: {
32
+ // axe label is off here: once a chip is in the field the input drops its placeholder
33
+ // and has no name of its own, and no prop lets the caller give it one. Settled at the
34
+ // Combobox port (PLAN.md stage 5).
35
+ a11y: { config: { rules: [{ id: 'label', enabled: false }] } },
32
36
  // The suggestion list portals to document.body at fixed coordinates —
33
37
  // render docs usage in an iframe so it lands where the field is.
34
38
  docs: { story: { inline: false, height: '320px' } },
@@ -40,7 +40,7 @@ export function DialogShell({ title, onClose, headerContent, footer, children, b
40
40
  return createPortal(
41
41
  <>
42
42
  {/* Backdrop */}
43
- <div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
43
+ <div className="fixed inset-0 z-40 bg-scrim" onClick={onClose} />
44
44
 
45
45
  {/* Dialog */}
46
46
  <div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
@@ -49,7 +49,12 @@ export const Multiline: Story = {
49
49
  }
50
50
 
51
51
  /** Empty shows the placeholder, muted — still clickable. */
52
- export const Empty: Story = { args: { value: '' } }
52
+ export const Empty: Story = {
53
+ args: { value: '' },
54
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
55
+ // the placeholder is muted text, 3.94:1 on --bg-base in signal (AA 4.5:1).
56
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
57
+ }
53
58
 
54
59
  /** For a reader who cannot write: the value alone, no edit affordance. */
55
60
  export const ReadOnly: Story = { args: { readOnly: true } }
@@ -40,7 +40,18 @@ import { IconPencil } from '@tabler/icons-react'
40
40
  control. The `tooltip` shows sighted hover users the same word; it does
41
41
  not replace the label.
42
42
  - The icon is 16px at stroke 1.5.
43
- - `type` defaults to `"button"`; every native prop passes through.
43
+ - `type` defaults to `"button"`; it is a native `<button>` on Base UI's
44
+ Button, and every native prop passes through.
45
+ - `disabledReason="Read only"` disables it, keeps it reachable by Tab,
46
+ and shows the reason in place of the tooltip (on hover for now; on
47
+ keyboard focus too once Tooltip moves onto Base UI, stage 4).
48
+
49
+ ## Keys
50
+
51
+ | Key | Does |
52
+ |---|---|
53
+ | Tab | Onto the button, also when it is disabled with a reason; never when it is plain `disabled`. |
54
+ | Space / Enter | The action. Nothing while disabled. |
44
55
 
45
56
  ## Props
46
57
 
@@ -5,7 +5,8 @@ import { IconButton } from './IconButton'
5
5
  const meta = {
6
6
  title: 'Primitives/IconButton',
7
7
  component: IconButton,
8
- args: { variant: 'muted', disabled: false, children: <IconSettings className="size-4" stroke={1.5} /> },
8
+ // An icon-only button owes its name; axe's button-name rule fails without it.
9
+ args: { variant: 'muted', disabled: false, 'aria-label': 'Settings', children: <IconSettings className="size-4" stroke={1.5} /> },
9
10
  argTypes: {
10
11
  variant: { control: 'inline-radio', options: ['muted', 'outlined', 'primary'] },
11
12
  tooltipPlacement: { control: 'inline-radio', options: ['top', 'bottom'] },
@@ -22,18 +23,22 @@ export const Primary: Story = { args: { variant: 'primary' } }
22
23
  export const Disabled: Story = { args: { variant: 'primary', disabled: true } }
23
24
  /** Hover to see the portalled tooltip (top or bottom placement). */
24
25
  export const WithTooltip: Story = { args: { tooltip: 'Settings', tooltipPlacement: 'top' } }
26
+ /** Disabled with its reason in place of the tooltip; Tab still reaches it. */
27
+ export const WithAReason: Story = { args: { tooltip: 'Settings', disabledReason: 'Sign in to change settings' } }
25
28
 
26
29
  /** Every variant × enabled/disabled. */
27
30
  export const AllVariants: Story = {
28
- parameters: { controls: { disable: true } },
31
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
32
+ // the variant captions are muted text, 3.94:1 on --bg-base in signal (AA 4.5:1).
33
+ parameters: { controls: { disable: true }, a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
29
34
  render: () => (
30
35
  <div className="flex flex-col gap-3">
31
36
  {(['muted', 'outlined', 'primary'] as const).map((variant) => (
32
37
  <div key={variant} className="flex items-center gap-2">
33
- <IconButton variant={variant}>
38
+ <IconButton variant={variant} aria-label="Settings">
34
39
  <IconSettings className="size-4" stroke={1.5} />
35
40
  </IconButton>
36
- <IconButton variant={variant} disabled>
41
+ <IconButton variant={variant} disabled aria-label="Settings">
37
42
  <IconSettings className="size-4" stroke={1.5} />
38
43
  </IconButton>
39
44
  <span className="text-[12px] leading-[120%] text-text-muted">{variant}</span>
@@ -0,0 +1,102 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the IconButton page claims, pinned: the label, the tooltip on hover,
4
+ * and `disabledReason` in place of the tooltip (stage 2 of the migration).
5
+ */
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { cleanup, render, screen } from '@testing-library/react'
8
+ import userEvent from '@testing-library/user-event'
9
+ import { IconButton } from './IconButton'
10
+
11
+ afterEach(cleanup)
12
+
13
+ const icon = <svg data-testid="icon" />
14
+
15
+ describe('IconButton', () => {
16
+ it('is a native button named by its aria-label, type button', async () => {
17
+ const user = userEvent.setup()
18
+ const onClick = vi.fn()
19
+ render(
20
+ <IconButton aria-label="Edit" onClick={onClick}>
21
+ {icon}
22
+ </IconButton>,
23
+ )
24
+ const button = screen.getByRole('button', { name: 'Edit' })
25
+ expect(button.tagName).toBe('BUTTON')
26
+ expect(button.getAttribute('type')).toBe('button')
27
+ await user.click(button)
28
+ expect(onClick).toHaveBeenCalledTimes(1)
29
+ })
30
+
31
+ it('shows the tooltip on hover, with its shortcut', async () => {
32
+ const user = userEvent.setup()
33
+ render(
34
+ <IconButton aria-label="Edit" tooltip="Edit" tooltipShortcut="E">
35
+ {icon}
36
+ </IconButton>,
37
+ )
38
+ expect(screen.queryByRole('tooltip')).toBeNull()
39
+ await user.hover(screen.getByRole('button', { name: 'Edit' }))
40
+ expect(screen.getByRole('tooltip').textContent).toBe('EditE')
41
+ })
42
+
43
+ it('disabledReason: disabled, reachable by Tab, the reason replaces the tooltip', async () => {
44
+ const user = userEvent.setup()
45
+ const onClick = vi.fn()
46
+ render(
47
+ <IconButton aria-label="Edit" tooltip="Edit" tooltipShortcut="E" disabledReason="Read only" onClick={onClick}>
48
+ {icon}
49
+ </IconButton>,
50
+ )
51
+ const button = screen.getByRole('button', { name: 'Edit' })
52
+ expect(button.getAttribute('aria-disabled')).toBe('true')
53
+ expect(button.hasAttribute('disabled')).toBe(false)
54
+ await user.click(button)
55
+ expect(onClick).not.toHaveBeenCalled()
56
+ await user.tab()
57
+ expect(document.activeElement).toBe(button)
58
+ await user.hover(button)
59
+ expect(screen.getByRole('tooltip').textContent).toBe('Read only')
60
+ })
61
+
62
+ it('Space and Enter are the action, and nothing while disabled with a reason', async () => {
63
+ const user = userEvent.setup()
64
+ const onClick = vi.fn()
65
+ const { unmount } = render(
66
+ <IconButton aria-label="Edit" onClick={onClick}>
67
+ {icon}
68
+ </IconButton>,
69
+ )
70
+ await user.tab()
71
+ await user.keyboard('{Enter}')
72
+ await user.keyboard(' ')
73
+ expect(onClick).toHaveBeenCalledTimes(2)
74
+ unmount()
75
+
76
+ const held = vi.fn()
77
+ render(
78
+ <IconButton aria-label="Edit" disabledReason="Read only" onClick={held}>
79
+ {icon}
80
+ </IconButton>,
81
+ )
82
+ await user.tab()
83
+ await user.keyboard('{Enter}')
84
+ await user.keyboard(' ')
85
+ expect(held).not.toHaveBeenCalled()
86
+ })
87
+
88
+ it('disabled without a reason: a real disabled button, out of the Tab order', async () => {
89
+ const user = userEvent.setup()
90
+ render(
91
+ <>
92
+ <IconButton aria-label="Edit" disabled>
93
+ {icon}
94
+ </IconButton>
95
+ <button type="button">After</button>
96
+ </>,
97
+ )
98
+ expect(screen.getByRole('button', { name: 'Edit' }).hasAttribute('disabled')).toBe(true)
99
+ await user.tab()
100
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: 'After' }))
101
+ })
102
+ })
@@ -1,4 +1,5 @@
1
1
  import type { ButtonHTMLAttributes, ReactNode } from 'react'
2
+ import { Button as BaseButton } from '@base-ui/react/button'
2
3
  import { cn } from './cn'
3
4
  import { WithTooltip } from './Tooltip'
4
5
 
@@ -6,7 +7,11 @@ import { WithTooltip } from './Tooltip'
6
7
  * Peek's IconButton (2026-08-28), verbatim: a square 4px-padded button
7
8
  * around a 16px icon, 8px radius, three variants, an optional tooltip that
8
9
  * portals above or below. Plus `type="button"` by default (Ship's addition),
9
- * so it never submits a form by accident.
10
+ * so it never submits a form by accident. On Base UI's Button since stage 2
11
+ * of the migration (2026-09-07).
12
+ *
13
+ * `disabledReason` disables it, keeps it reachable by keyboard, and shows
14
+ * the reason in place of the tooltip — see Button.
10
15
  */
11
16
  export type IconButtonVariant = 'muted' | 'outlined' | 'primary'
12
17
 
@@ -17,6 +22,9 @@ export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement>
17
22
  * whose only other affordance is a keyboard shortcut. */
18
23
  tooltipShortcut?: string
19
24
  tooltipPlacement?: 'top' | 'bottom'
25
+ /** Why the action cannot succeed right now. Disables the button, keeps it
26
+ * reachable by keyboard, and shows the reason as the tooltip. */
27
+ disabledReason?: string
20
28
  /** The icon: 16px, stroke 1.5. */
21
29
  children: ReactNode
22
30
  }
@@ -26,6 +34,7 @@ export function IconButton({
26
34
  className,
27
35
  children,
28
36
  disabled,
37
+ disabledReason,
29
38
  tooltip,
30
39
  tooltipShortcut,
31
40
  tooltipPlacement,
@@ -33,29 +42,33 @@ export function IconButton({
33
42
  ...props
34
43
  }: IconButtonProps) {
35
44
  const button = (
36
- <button
45
+ <BaseButton
37
46
  type={type}
38
- className={cn(
39
- 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 cursor-pointer',
40
- !disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
41
- !disabled && variant === 'muted' && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
42
- !disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
43
- disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
44
- disabled && variant === 'muted' && 'text-text-disabled',
45
- disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
46
- disabled && 'pointer-events-none cursor-not-allowed',
47
- className,
48
- )}
49
- disabled={disabled}
47
+ disabled={disabled || !!disabledReason}
48
+ focusableWhenDisabled={!!disabledReason}
49
+ className={(state) =>
50
+ cn(
51
+ 'flex items-center justify-center p-1 rounded-lg transition-colors shrink-0 cursor-pointer',
52
+ !state.disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse',
53
+ !state.disabled && variant === 'muted' && 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
54
+ !state.disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-secondary',
55
+ state.disabled && variant === 'primary' && 'bg-bg-disabled text-text-disabled',
56
+ state.disabled && variant === 'muted' && 'text-text-disabled',
57
+ state.disabled && variant === 'outlined' && 'border border-border-default text-text-disabled',
58
+ state.disabled && 'pointer-events-none cursor-not-allowed',
59
+ className,
60
+ )
61
+ }
50
62
  {...props}
51
63
  >
52
64
  {children}
53
- </button>
65
+ </BaseButton>
54
66
  )
55
67
 
56
- if (tooltip) {
68
+ const label = disabledReason ?? tooltip
69
+ if (label) {
57
70
  return (
58
- <WithTooltip label={tooltip} shortcut={tooltipShortcut} placement={tooltipPlacement}>
71
+ <WithTooltip label={label} shortcut={disabledReason ? undefined : tooltipShortcut} placement={tooltipPlacement}>
59
72
  {button}
60
73
  </WithTooltip>
61
74
  )
@@ -26,7 +26,11 @@ export default meta
26
26
  type Story = StoryObj<typeof meta>
27
27
 
28
28
  /** A browser-held key: silhouette, "Acting as", and the honest sentence. */
29
- export const Anonymous: Story = {}
29
+ export const Anonymous: Story = {
30
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
31
+ // the fallback name is muted text, 3.50:1 on --bg-elevated in signal (AA 4.5:1).
32
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
33
+ }
30
34
 
31
35
  /** Signed in through Estiva ID, in a build that offers sign-in. */
32
36
  export const SignedIn: Story = {
package/src/Kbd.tsx CHANGED
@@ -31,8 +31,8 @@ export function Kbd({ children, className }: KbdProps) {
31
31
  <kbd
32
32
  className={cn(
33
33
  'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-[12px] leading-[120%] font-normal text-text-secondary',
34
- 'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-[rgba(255,255,255,.05)] signal:font-mono signal:text-[10px]',
35
- 'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-[rgba(255,255,255,.05)] ship:font-mono ship:text-[10px]',
34
+ 'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-bg-wash signal:font-mono signal:text-[10px]',
35
+ 'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-bg-wash ship:font-mono ship:text-[10px]',
36
36
  className,
37
37
  )}
38
38
  >
@@ -23,6 +23,9 @@ export const Active: Story = { args: { active: true } }
23
23
  /** The count is a muted mono number, and its tooltip says what it counts. */
24
24
  export const WithIconAndCount: Story = {
25
25
  args: { icon: placeholder, count: 18, countLabel: '18 open' },
26
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
27
+ // the count is muted text, 3.94:1 on --bg-base in signal (AA 4.5:1).
28
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
26
29
  }
27
30
 
28
31
  /** A zero is not drawn at all — this row's rule (a tab draws its zero; both are deliberate). */
@@ -21,6 +21,9 @@ export const Unnamed: Story = {
21
21
  /** Where the unnamed person is *you*, pass a word instead of the dash. */
22
22
  export const OwnFallback: Story = {
23
23
  args: { name: undefined, fallback: 'Anonymous' },
24
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
25
+ // the fallback name is muted text, 3.94:1 on --bg-base in signal (AA 4.5:1).
26
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
24
27
  }
25
28
 
26
29
  /** The text size is the caller's, so face and words are set together. */
@@ -44,7 +44,15 @@ import { PersonTrigger } from '@estiva-app/ui'
44
44
  - A toggle trigger must swallow its `mousedown` (as above) or the menu's
45
45
  outside-click dismiss turns each press into close-then-reopen — the
46
46
  trap is spelled out on the Menu page.
47
- - It takes a `ref` and every native button prop anchor your menu on it.
47
+ - It is a native `<button>` on Base UI's Button: it takes a `ref` and
48
+ every native button prop — anchor your menu on it.
49
+
50
+ ## Keys
51
+
52
+ | Key | Does |
53
+ |---|---|
54
+ | Tab | Onto it. |
55
+ | Space / Enter | Your `onClick`: open what it opens. |
48
56
 
49
57
  ## Props
50
58
 
@@ -22,6 +22,9 @@ export const RowOpen: Story = {
22
22
  /** The row for someone with no published name. */
23
23
  export const RowUnnamed: Story = {
24
24
  args: { name: undefined, fallback: 'Anonymous' },
25
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
26
+ // the fallback name is muted text, 3.94:1 on --bg-base in signal (AA 4.5:1).
27
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
25
28
  }
26
29
 
27
30
  /** The face alone — Peek's top-bar shape. No chevron, no padding, and no hover fill: the face fills the whole control, so a fill would have nowhere to show. */
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the PersonTrigger page claims, pinned: a menu button named by the
4
+ * person, `open` on `aria-expanded`, a ref that reaches the button (stage 2
5
+ * of the migration).
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, render, screen } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { createRef } from 'react'
11
+ import { PersonTrigger } from './PersonTrigger'
12
+
13
+ afterEach(cleanup)
14
+
15
+ describe('PersonTrigger', () => {
16
+ it('the row is a menu button named by the person, and open shows on it', () => {
17
+ const { rerender } = render(<PersonTrigger name="Ana Duarte" />)
18
+ // The face's initials are text too, so the name reads "AD Ana Duarte"
19
+ // today; that is the Avatar's to settle at its own port (stage 6).
20
+ const button = screen.getByRole('button', { name: /Ana Duarte$/ })
21
+ expect(button.tagName).toBe('BUTTON')
22
+ expect(button.getAttribute('type')).toBe('button')
23
+ expect(button.getAttribute('aria-haspopup')).toBe('menu')
24
+ expect(button.getAttribute('aria-expanded')).toBe('false')
25
+ rerender(<PersonTrigger name="Ana Duarte" open />)
26
+ expect(button.getAttribute('aria-expanded')).toBe('true')
27
+ })
28
+
29
+ it('Tab reaches it; Space and Enter are the click', async () => {
30
+ const user = userEvent.setup()
31
+ const onClick = vi.fn()
32
+ render(<PersonTrigger name="Ana Duarte" onClick={onClick} />)
33
+ await user.tab()
34
+ expect(document.activeElement).toBe(screen.getByRole('button', { name: /Ana Duarte$/ }))
35
+ await user.keyboard('{Enter}')
36
+ await user.keyboard(' ')
37
+ expect(onClick).toHaveBeenCalledTimes(2)
38
+ })
39
+
40
+ it('the compact face takes the label it is given, and a ref and handlers reach the button', async () => {
41
+ const user = userEvent.setup()
42
+ const ref = createRef<HTMLButtonElement>()
43
+ const onMouseDown = vi.fn()
44
+ const onClick = vi.fn()
45
+ render(<PersonTrigger name="Ana Duarte" compact aria-label="Account menu" ref={ref} onMouseDown={onMouseDown} onClick={onClick} />)
46
+ const button = screen.getByRole('button', { name: 'Account menu' })
47
+ expect(ref.current).toBe(button)
48
+ await user.click(button)
49
+ expect(onMouseDown).toHaveBeenCalledTimes(1)
50
+ expect(onClick).toHaveBeenCalledTimes(1)
51
+ })
52
+ })
@@ -1,11 +1,13 @@
1
1
  import type { ComponentPropsWithRef } from 'react'
2
+ import { Button as BaseButton } from '@base-ui/react/button'
2
3
  import { IconChevronDown } from '@tabler/icons-react'
3
4
  import { cn } from './cn'
4
5
  import { Avatar } from './Avatar'
5
6
  import { Person, type PersonProps } from './Person'
6
7
 
7
8
  /**
8
- * The person, as the button that opens the account menu.
9
+ * The person, as the button that opens the account menu. On Base UI's Button
10
+ * since stage 2 of the migration (2026-09-07).
9
11
  *
10
12
  * Two shapes, one component (Katerina, 2026-09-01):
11
13
  *
@@ -29,7 +31,7 @@ export interface PersonTriggerProps
29
31
  export function PersonTrigger({ name, picture, fallback, size, open = false, compact = false, className, ...props }: PersonTriggerProps) {
30
32
  if (compact) {
31
33
  return (
32
- <button
34
+ <BaseButton
33
35
  type="button"
34
36
  aria-haspopup="menu"
35
37
  aria-expanded={open}
@@ -37,19 +39,16 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
37
39
  {...props}
38
40
  >
39
41
  <Avatar name={name} src={picture} size={size ?? 36} />
40
- </button>
42
+ </BaseButton>
41
43
  )
42
44
  }
43
45
  return (
44
- <button
46
+ <BaseButton
45
47
  type="button"
46
48
  aria-haspopup="menu"
47
49
  aria-expanded={open}
48
50
  className={cn(
49
- // The size is an arbitrary value (the body-2 token): this list goes through
50
- // cn(), and tw-merge silently drops a custom text-{size} once a
51
- // text-{colour} follows it. Measured: the name rendered 16px.
52
- 'flex h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-[14px] leading-[140%] text-text-primary transition-colors hover:bg-bg-hover',
51
+ 'flex h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-body-2 text-text-primary transition-colors hover:bg-bg-hover',
53
52
  open && 'bg-bg-hover',
54
53
  className,
55
54
  )}
@@ -57,6 +56,6 @@ export function PersonTrigger({ name, picture, fallback, size, open = false, com
57
56
  >
58
57
  <Person name={name} picture={picture} fallback={fallback} size={size ?? 22} />
59
58
  <IconChevronDown size={14} stroke={1.5} className="shrink-0 text-text-muted" />
60
- </button>
59
+ </BaseButton>
61
60
  )
62
61
  }
@@ -49,7 +49,12 @@ function Demo(props: Omit<ComponentProps<typeof Select>, 'value' | 'onChange'> &
49
49
  export const Default: Story = { render: (args) => <Demo {...args} initial="in_progress" /> }
50
50
 
51
51
  /** Nothing chosen yet: the placeholder is muted, so an empty select does not read as one already holding a value. */
52
- export const Empty: Story = { render: (args) => <Demo {...args} initial="" placeholder="Choose a status…" /> }
52
+ export const Empty: Story = {
53
+ render: (args) => <Demo {...args} initial="" placeholder="Choose a status…" />,
54
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
55
+ // the placeholder is muted text, 3.21:1 on the field in signal (AA 4.5:1).
56
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
57
+ }
53
58
 
54
59
  /** The dense size, matching `Button`'s small — for property rows, where a full-height field would dominate the label beside it. */
55
60
  export const Small: Story = { render: (args) => <Demo {...args} size="small" initial="todo" /> }
@@ -181,7 +186,7 @@ export const LongLabelInPropertyRow: Story = {
181
186
  render: (args) => (
182
187
  <div className="w-[300px] rounded-lg border border-border-subtle p-3">
183
188
  <div className="flex items-center gap-2">
184
- <span className="w-[68px] shrink-0 text-xs text-text-secondary">Lead</span>
189
+ <span className="w-[68px] shrink-0 text-caption text-text-secondary">Lead</span>
185
190
  <Demo
186
191
  {...args}
187
192
  size="small"
@@ -20,6 +20,9 @@ type Story = StoryObj<typeof meta>
20
20
 
21
21
  /** Entries, then a labelled group — the heading is a SectionLabel in a 32px row. */
22
22
  export const Composed: Story = {
23
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
24
+ // the count chip is muted text, 3.06:1 on the active row in signal (AA 4.5:1).
25
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
23
26
  render: (args) => (
24
27
  <Sidebar {...args}>
25
28
  <NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" active />
@@ -36,6 +39,9 @@ export const Composed: Story = {
36
39
 
37
40
  /** The column scrolls on its own — a long list never scrolls the frame away. */
38
41
  export const Scrolls: Story = {
42
+ // axe color-contrast is off here until PLAN.md stage 0.10 is ruled:
43
+ // the count chips are muted text, 3.78:1 on --bg-surface in signal (AA 4.5:1).
44
+ parameters: { a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } } },
39
45
  render: (args) => (
40
46
  <Sidebar {...args}>
41
47
  <NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" />