@estiva-app/ui 0.9.0 → 0.10.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 (151) hide show
  1. package/dist/Avatar.d.ts +13 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +16 -1
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/Breadcrumb.d.ts +9 -0
  7. package/dist/Breadcrumb.d.ts.map +1 -1
  8. package/dist/Button.d.ts +12 -2
  9. package/dist/Button.d.ts.map +1 -1
  10. package/dist/Checkbox.d.ts +2 -0
  11. package/dist/Checkbox.d.ts.map +1 -1
  12. package/dist/Chip.d.ts +3 -2
  13. package/dist/Chip.d.ts.map +1 -1
  14. package/dist/ChipInput.d.ts +3 -1
  15. package/dist/ChipInput.d.ts.map +1 -1
  16. package/dist/ConfirmDialog.d.ts +5 -0
  17. package/dist/ConfirmDialog.d.ts.map +1 -1
  18. package/dist/DialogShell.d.ts +27 -3
  19. package/dist/DialogShell.d.ts.map +1 -1
  20. package/dist/EditableText.d.ts +6 -0
  21. package/dist/EditableText.d.ts.map +1 -1
  22. package/dist/Field.d.ts +42 -25
  23. package/dist/Field.d.ts.map +1 -1
  24. package/dist/IconButton.d.ts +3 -2
  25. package/dist/IconButton.d.ts.map +1 -1
  26. package/dist/IdentityMenu.d.ts +25 -13
  27. package/dist/IdentityMenu.d.ts.map +1 -1
  28. package/dist/Menu.d.ts +65 -82
  29. package/dist/Menu.d.ts.map +1 -1
  30. package/dist/PersonTrigger.d.ts.map +1 -1
  31. package/dist/Popover.d.ts +86 -0
  32. package/dist/Popover.d.ts.map +1 -0
  33. package/dist/PreviewCard.d.ts +39 -0
  34. package/dist/PreviewCard.d.ts.map +1 -0
  35. package/dist/Reaction.d.ts.map +1 -1
  36. package/dist/ReactionPicker.d.ts +55 -0
  37. package/dist/ReactionPicker.d.ts.map +1 -0
  38. package/dist/SearchInput.d.ts +5 -0
  39. package/dist/SearchInput.d.ts.map +1 -1
  40. package/dist/Select.d.ts +26 -16
  41. package/dist/Select.d.ts.map +1 -1
  42. package/dist/Tabs.d.ts +11 -1
  43. package/dist/Tabs.d.ts.map +1 -1
  44. package/dist/TextInput.d.ts +7 -1
  45. package/dist/TextInput.d.ts.map +1 -1
  46. package/dist/Textarea.d.ts +8 -1
  47. package/dist/Textarea.d.ts.map +1 -1
  48. package/dist/Toolbar.d.ts +95 -0
  49. package/dist/Toolbar.d.ts.map +1 -0
  50. package/dist/Tooltip.d.ts +36 -10
  51. package/dist/Tooltip.d.ts.map +1 -1
  52. package/dist/cn.d.ts.map +1 -1
  53. package/dist/fit.d.ts +7 -72
  54. package/dist/fit.d.ts.map +1 -1
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +882 -763
  58. package/dist/index.js.map +4 -4
  59. package/package.json +1 -1
  60. package/src/Avatar.mdx +17 -0
  61. package/src/Avatar.name.test.tsx +99 -0
  62. package/src/Avatar.tsx +25 -3
  63. package/src/AvatarGroup.tsx +4 -1
  64. package/src/Banner.mdx +10 -2
  65. package/src/Banner.stories.tsx +29 -0
  66. package/src/Banner.test.tsx +60 -0
  67. package/src/Banner.tsx +35 -3
  68. package/src/Breadcrumb.mdx +5 -2
  69. package/src/Breadcrumb.test.tsx +44 -0
  70. package/src/Breadcrumb.tsx +10 -2
  71. package/src/Button.compose.test.tsx +119 -0
  72. package/src/Button.mdx +7 -5
  73. package/src/Button.test.tsx +2 -1
  74. package/src/Button.tsx +21 -5
  75. package/src/Checkbox.tsx +3 -0
  76. package/src/Chip.tsx +3 -2
  77. package/src/ChipInput.tsx +4 -0
  78. package/src/ConfirmDialog.mdx +20 -1
  79. package/src/ConfirmDialog.test.tsx +109 -0
  80. package/src/ConfirmDialog.tsx +6 -0
  81. package/src/DialogShell.mdx +18 -4
  82. package/src/DialogShell.stories.tsx +56 -6
  83. package/src/DialogShell.test.tsx +184 -0
  84. package/src/DialogShell.tsx +108 -39
  85. package/src/EditableText.mdx +6 -1
  86. package/src/EditableText.test.tsx +156 -0
  87. package/src/EditableText.tsx +23 -5
  88. package/src/Field.mdx +23 -3
  89. package/src/Field.stories.tsx +35 -0
  90. package/src/Field.test.tsx +117 -27
  91. package/src/Field.tsx +86 -58
  92. package/src/IconButton.mdx +6 -2
  93. package/src/IconButton.test.tsx +5 -2
  94. package/src/IconButton.tsx +12 -6
  95. package/src/IdentityMenu.mdx +29 -6
  96. package/src/IdentityMenu.stories.tsx +41 -6
  97. package/src/IdentityMenu.tsx +74 -47
  98. package/src/Kbd.stories.tsx +3 -3
  99. package/src/Menu.mdx +88 -75
  100. package/src/Menu.stories.tsx +120 -52
  101. package/src/Menu.test.tsx +315 -0
  102. package/src/Menu.tsx +341 -251
  103. package/src/MenuItem.stories.tsx +135 -98
  104. package/src/NavItem.mdx +1 -1
  105. package/src/PersonTrigger.mdx +20 -14
  106. package/src/PersonTrigger.tsx +23 -1
  107. package/src/Popover.mdx +130 -0
  108. package/src/Popover.stories.tsx +222 -0
  109. package/src/Popover.test.tsx +188 -0
  110. package/src/Popover.tsx +153 -0
  111. package/src/PreviewCard.mdx +98 -0
  112. package/src/PreviewCard.stories.tsx +168 -0
  113. package/src/PreviewCard.test.tsx +83 -0
  114. package/src/PreviewCard.tsx +91 -0
  115. package/src/Rail.mdx +63 -0
  116. package/src/Rail.stories.tsx +81 -0
  117. package/src/Reaction.mdx +4 -2
  118. package/src/Reaction.tsx +4 -2
  119. package/src/ReactionPicker.mdx +85 -0
  120. package/src/ReactionPicker.stories.tsx +120 -0
  121. package/src/ReactionPicker.test.tsx +118 -0
  122. package/src/ReactionPicker.tsx +88 -0
  123. package/src/SearchInput.mdx +6 -2
  124. package/src/SearchInput.tsx +7 -1
  125. package/src/Select.mdx +16 -5
  126. package/src/Select.test.tsx +157 -0
  127. package/src/Select.tsx +89 -214
  128. package/src/Sidebar.mdx +6 -2
  129. package/src/Tabs.mdx +5 -0
  130. package/src/Tabs.test.tsx +15 -0
  131. package/src/Tabs.tsx +17 -2
  132. package/src/TextInput.mdx +8 -2
  133. package/src/TextInput.tsx +10 -7
  134. package/src/Textarea.mdx +6 -2
  135. package/src/Textarea.tsx +14 -9
  136. package/src/Toolbar.mdx +104 -0
  137. package/src/Toolbar.stories.tsx +142 -0
  138. package/src/Toolbar.test.tsx +198 -0
  139. package/src/Toolbar.tsx +158 -0
  140. package/src/Tooltip.mdx +43 -5
  141. package/src/Tooltip.stories.tsx +26 -0
  142. package/src/Tooltip.test.tsx +195 -0
  143. package/src/Tooltip.tsx +150 -48
  144. package/src/cn.ts +1 -1
  145. package/src/fit.test.ts +101 -0
  146. package/src/fit.ts +19 -63
  147. package/src/index.ts +6 -3
  148. package/stories/Choosing.mdx +8 -3
  149. package/tailwind-preset.js +7 -0
  150. package/src/Menu.fit.test.ts +0 -90
  151. package/src/Select.fit.test.ts +0 -101
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",
package/src/Avatar.mdx CHANGED
@@ -42,6 +42,23 @@ import { Avatar } from '@estiva-app/ui'
42
42
  rounder geometry wraps the Avatar concentrically — it does not override
43
43
  the Avatar's own radius.
44
44
 
45
+ ### What it says out loud
46
+
47
+ **A face is silent by default**, and that is the important half: almost every
48
+ face in the suite sits beside the name it belongs to — in a `Person`, in a
49
+ `MenuItem`'s `leading`, in a row — and a picture that spoke there would say the
50
+ name twice. It used to. Measured 2026-09-08, before this was true: a `Person`
51
+ inside a button announced **"AD Ana Duarte"** (the initials read as text), and
52
+ with a picture **"Ana Duarte Ana Duarte"**.
53
+
54
+ **`label` names a face that stands on its own** — the compact `PersonTrigger`,
55
+ a stack of members — and the tile becomes one image with one name.
56
+
57
+ ```tsx
58
+ <Avatar name={person.name} /> {/* beside a name: silent */}
59
+ <Avatar name={person.name} label={person.name} /> {/* alone: says who it is */}
60
+ ```
61
+
45
62
  <Canvas of={AvatarStories.Palette} />
46
63
 
47
64
  ## Props
@@ -0,0 +1,99 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What a face is called, everywhere one appears.
4
+ *
5
+ * These are the names an app's own tests compute — `getByRole('button', { name })`
6
+ * runs the same algorithm — and every one of them was wrong until 2026-09-08:
7
+ * a face read as its initials, and a face beside a name read the name twice.
8
+ * The initials are a drawing of a name, not text.
9
+ */
10
+ import { afterEach, describe, expect, it } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import { Avatar } from './Avatar'
13
+ import { AvatarGroup } from './AvatarGroup'
14
+ import { MenuItem, MenuPanel } from './Menu'
15
+ import { Person } from './Person'
16
+ import { PersonTrigger } from './PersonTrigger'
17
+
18
+ afterEach(cleanup)
19
+
20
+ /**
21
+ * `getByRole`'s `name` matches the *accessible* name exactly, computed by the
22
+ * same algorithm a browser uses — so a query that finds nothing is the failure
23
+ * being asserted, and the message names what it looked for. This is also how
24
+ * both apps query their own controls, which is what makes it the right test.
25
+ */
26
+
27
+ describe('a face beside a name says nothing of its own', () => {
28
+ it('a button holding a Person is called the person, once', () => {
29
+ render(
30
+ <button type="button">
31
+ <Person name="Ana Duarte" />
32
+ </button>,
33
+ )
34
+ // "AD Ana Duarte" before: the initials were read as text.
35
+ expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
36
+ })
37
+
38
+ it('and once when there is a picture rather than initials', () => {
39
+ render(
40
+ <button type="button">
41
+ <Person name="Ana Duarte" picture="/a.png" />
42
+ </button>,
43
+ )
44
+ // "Ana Duarte Ana Duarte" before: the alt text and the name were both read.
45
+ expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
46
+ })
47
+
48
+ it('a menu row led by a face is called by its label', () => {
49
+ render(
50
+ <MenuPanel>
51
+ <MenuItem label="Ana Duarte" leading={<Avatar name="Ana Duarte" size={32} />} onClick={() => {}} />
52
+ </MenuPanel>,
53
+ )
54
+ expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
55
+ })
56
+
57
+ it('a bare face is not in the accessibility tree at all', () => {
58
+ const { container } = render(<Avatar name="Ana Duarte" />)
59
+ expect(container.firstElementChild?.getAttribute('aria-hidden')).toBe('true')
60
+ })
61
+ })
62
+
63
+ describe('a face that stands on its own says whose it is', () => {
64
+ it('the compact PersonTrigger is called the person', () => {
65
+ render(<PersonTrigger name="Ana Duarte" compact />)
66
+ // "AD" before — the initials, and the only name the control had.
67
+ expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
68
+ })
69
+
70
+ it('a caller may name the destination instead', () => {
71
+ render(<PersonTrigger name="Ana Duarte" compact aria-label="Account menu" />)
72
+ expect(screen.getByRole('button', { name: 'Account menu' })).toBeTruthy()
73
+ })
74
+
75
+ it('an unnamed person falls back to what the caller calls them', () => {
76
+ render(<PersonTrigger fallback="Anonymous" compact />)
77
+ expect(screen.getByRole('button', { name: 'Anonymous' })).toBeTruthy()
78
+ })
79
+
80
+ it('the row shape is called the person, without the initials in front', () => {
81
+ render(<PersonTrigger name="Ana Duarte" />)
82
+ expect(screen.getByRole('button', { name: 'Ana Duarte' })).toBeTruthy()
83
+ })
84
+
85
+ it('a stack of faces names each of them', () => {
86
+ render(<AvatarGroup members={[{ name: 'Ana Duarte' }, { name: 'Ben Carter' }]} />)
87
+ // "AD" and "BC" before.
88
+ expect(screen.getAllByRole('img').map((el) => el.getAttribute('aria-label'))).toEqual(['Ana Duarte', 'Ben Carter'])
89
+ })
90
+ })
91
+
92
+ describe('the compact trigger keeps a focus ring', () => {
93
+ /** `focus:outline-none` took the browser's ring away and put nothing back,
94
+ * so the account trigger had no visible focus at all. */
95
+ it('does not remove its own outline', () => {
96
+ render(<PersonTrigger name="Ana Duarte" compact />)
97
+ expect(screen.getByRole('button').className).not.toContain('outline-none')
98
+ })
99
+ })
package/src/Avatar.tsx CHANGED
@@ -52,17 +52,36 @@ export interface AvatarProps {
52
52
  alt?: string
53
53
  /** Pixels. The scale: 16 · 24 · 32 · 36 (default). */
54
54
  size?: number
55
+ /**
56
+ * **Say the person's name out loud**, for a face that stands on its own —
57
+ * `PersonTrigger`'s compact shape, a stack of members.
58
+ *
59
+ * A face is silent by default, because almost every face in the suite sits
60
+ * beside the name it belongs to: `Person`, a `MenuItem`'s `leading`, a row.
61
+ * A picture that spoke there would double the name — measured 2026-09-08,
62
+ * before this existed: a `Person` in a button announced **"AD Ana Duarte"**,
63
+ * and with a picture **"Ana Duarte Ana Duarte"**. The initials are a drawing
64
+ * of a name, not text, and a screen reader was reading them as text.
65
+ */
66
+ label?: string
55
67
  className?: string
56
68
  }
57
69
 
58
- export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProps) {
70
+ export function Avatar({ src, name, alt = '', size = 36, label: spoken, className }: AvatarProps) {
59
71
  const [broken, setBroken] = useState(false)
60
72
  const label = name || alt
61
73
  const picture = src && !broken ? src : undefined
62
74
  return (
63
- <div className={cn('rounded-sm overflow-hidden shrink-0 bg-bg-inset', className)} style={{ width: size, height: size }}>
75
+ <div
76
+ /* Named or silent, never half of either: with a `label` the tile is one
77
+ image with one name, and everything inside it is that image's pixels;
78
+ without one it is not in the accessibility tree at all. */
79
+ {...(spoken ? { role: 'img', 'aria-label': spoken } : { 'aria-hidden': true })}
80
+ className={cn('rounded-sm overflow-hidden shrink-0 bg-bg-inset', className)}
81
+ style={{ width: size, height: size }}
82
+ >
64
83
  {picture ? (
65
- <img src={picture} alt={alt || name || ''} className="w-full h-full object-cover" onError={() => setBroken(true)} />
84
+ <img src={picture} alt="" className="w-full h-full object-cover" onError={() => setBroken(true)} />
66
85
  ) : label ? (
67
86
  <div
68
87
  /*
@@ -80,6 +99,9 @@ export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProp
80
99
  */
81
100
  className="w-full h-full flex items-center justify-center font-semibold leading-none"
82
101
  style={{
102
+ // The one ink colour that reads on all eight hues, which are a
103
+ // palette rather than tokens (see the note at the top) — so its
104
+ // ink cannot be a token either.
83
105
  color: '#08121c',
84
106
  fontSize: Math.round(size * 0.36),
85
107
  background: `linear-gradient(160deg, color-mix(in srgb, ${hueFor(label)} 92%, #fff) 0%, color-mix(in srgb, ${hueFor(label)} 70%, #0b0d11) 100%)`,
@@ -57,7 +57,10 @@ export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
57
57
  className="relative flex rounded-sm"
58
58
  style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
59
59
  >
60
- <Avatar size={size} name={member.name} src={member.picture} alt={member.name} />
60
+ {/* Each face says whose it is: a stack stands on its own, with no
61
+ name beside it to borrow. It read as its initials before —
62
+ "AD BC CD" — which is the drawing, not the person. */}
63
+ <Avatar size={size} name={member.name} src={member.picture} label={member.name} />
61
64
  </span>
62
65
  ))}
63
66
  </div>
package/src/Banner.mdx CHANGED
@@ -23,6 +23,10 @@ line, one of four tones, gone when there is nothing to say.
23
23
  ## When not
24
24
 
25
25
  - Feedback about one action, briefly → **Toast**.
26
+ - Something that floats over the page and must **not** fade → still
27
+ **Toast**, with `durationMs: 0` and an action. A banner is the strip
28
+ under the header at the app's full width; a floating pill is not one, even
29
+ a permanent one (Katerina, D21).
26
30
  - A standing property of one thing → **Chip** on that thing.
27
31
  - A notice inside the content flow with its own action — that is a
28
32
  different component (not in the package yet).
@@ -37,8 +41,12 @@ import { Banner } from '@estiva-app/ui'
37
41
 
38
42
  - Render it only while there is something to say — an empty strip is not
39
43
  a state.
40
- - One line of plain text, `body-2`. If it needs a button or a second
41
- line, it has outgrown this component.
44
+ - One line of plain text, `body-2`. If it needs a **second line or an
45
+ action of its own**, it has outgrown this component.
46
+ - `onDismiss` adds an ✕ at the right-hand end (Katerina, D21). Use it where
47
+ the reader decides the notice is done with, rather than the app. The row
48
+ is 40px rather than 36px with it, because the button is taller than the
49
+ line of text; without it, nothing about the strip changes.
42
50
 
43
51
  ## Props
44
52
 
@@ -26,6 +26,14 @@ export const Info: Story = { args: { tone: 'info', children: 'A new version is a
26
26
 
27
27
  export const Warning: Story = { args: { tone: 'warning', children: 'This workspace is read-only until sign-in.' } }
28
28
 
29
+ /**
30
+ * With a dismiss (D21). The row is 40px rather than 36px, because the button
31
+ * is taller than the line of text.
32
+ */
33
+ export const Dismissible: Story = {
34
+ args: { tone: 'warning', children: 'This workspace is read-only until sign-in.', onDismiss: () => {} },
35
+ }
36
+
29
37
  /** All four tones, stacked. */
30
38
  export const AllTones: Story = {
31
39
  parameters: { controls: { disable: true } },
@@ -38,3 +46,24 @@ export const AllTones: Story = {
38
46
  </div>
39
47
  ),
40
48
  }
49
+
50
+ /** The same four with a dismiss, so the two heights can be compared. */
51
+ export const AllTonesDismissible: Story = {
52
+ parameters: { controls: { disable: true } },
53
+ render: () => (
54
+ <div className="flex flex-col gap-2">
55
+ <Banner tone="ok" onDismiss={() => {}}>
56
+ Public key copied.
57
+ </Banner>
58
+ <Banner tone="info" onDismiss={() => {}}>
59
+ A new version is available. Reload when convenient.
60
+ </Banner>
61
+ <Banner tone="warning" onDismiss={() => {}}>
62
+ This workspace is read-only until sign-in.
63
+ </Banner>
64
+ <Banner tone="error" onDismiss={() => {}}>
65
+ The last read failed — reconnecting.
66
+ </Banner>
67
+ </div>
68
+ ),
69
+ }
@@ -0,0 +1,60 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What `Banner.mdx` claims about the dismiss added at stage 3 (D21).
4
+ *
5
+ * The point of the first test is the one that is easy to lose: a banner
6
+ * without `onDismiss` must render exactly what it always did. Every banner in
7
+ * both apps is that one, and a prop that quietly changed their DOM would be a
8
+ * change nobody asked for.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { Banner } from './Banner'
14
+
15
+ afterEach(cleanup)
16
+
17
+ describe('Banner', () => {
18
+ it('is one element with the text in it when there is no dismiss', () => {
19
+ const { container } = render(<Banner tone="info">Two people are editing this.</Banner>)
20
+ const root = container.firstElementChild!
21
+ expect(root.children).toHaveLength(0)
22
+ expect(root.textContent).toBe('Two people are editing this.')
23
+ expect(screen.queryByRole('button')).toBeNull()
24
+ })
25
+
26
+ it('offers a dismiss when asked, and reports it', async () => {
27
+ const onDismiss = vi.fn()
28
+ const user = userEvent.setup()
29
+ render(
30
+ <Banner tone="warning" onDismiss={onDismiss}>
31
+ The relay is not answering.
32
+ </Banner>,
33
+ )
34
+ await user.click(screen.getByRole('button', { name: 'Dismiss' }))
35
+ expect(onDismiss).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it('lets the caller name the dismiss', () => {
39
+ render(
40
+ <Banner tone="ok" onDismiss={() => {}} dismissLabel="Hide this notice">
41
+ Saved.
42
+ </Banner>,
43
+ )
44
+ expect(screen.getByRole('button', { name: 'Hide this notice' })).toBeTruthy()
45
+ })
46
+
47
+ it('announces an error and stays polite otherwise', () => {
48
+ const { rerender, container } = render(<Banner tone="error">Could not save.</Banner>)
49
+ expect(container.firstElementChild!.getAttribute('role')).toBe('alert')
50
+ rerender(<Banner tone="warning">Could not save.</Banner>)
51
+ expect(container.firstElementChild!.getAttribute('role')).toBe('status')
52
+ // and the same either side of the dismiss
53
+ rerender(
54
+ <Banner tone="error" onDismiss={() => {}}>
55
+ Could not save.
56
+ </Banner>,
57
+ )
58
+ expect(container.firstElementChild!.getAttribute('role')).toBe('alert')
59
+ })
60
+ })
package/src/Banner.tsx CHANGED
@@ -1,4 +1,6 @@
1
+ import { IconX } from '@tabler/icons-react'
1
2
  import { cn } from './cn'
3
+ import { IconButton } from './IconButton'
2
4
 
3
5
  /**
4
6
  * The strip under the header where the app says something happened —
@@ -10,12 +12,27 @@ import { cn } from './cn'
10
12
  * rendered, so 14px is what it has always been and what she kept.
11
13
  * An `error` announces itself (`role="alert"`); the other tones are
12
14
  * polite (`role="status"`).
15
+ *
16
+ * `onDismiss` adds an `✕` at the right-hand end (Katerina, D21,
17
+ * 2026-09-07). Without it the strip is exactly what it was — a banner an
18
+ * app removes by not rendering it. With it the row is 40px rather than
19
+ * 36px, because the button is taller than the line of text.
20
+ *
21
+ * **A banner is not a toast.** This is the strip under the header, at the
22
+ * app's full width, for something that stays true. Transient feedback is
23
+ * `Toast`, and something that floats over the page and must not fade is a
24
+ * `Toast` with `durationMs: 0` — which is what Peek's "topic deleted"
25
+ * notice is (D21), not this.
13
26
  */
14
27
  export type BannerTone = 'ok' | 'error' | 'info' | 'warning'
15
28
 
16
29
  export interface BannerProps {
17
30
  tone: BannerTone
18
31
  children: string
32
+ /** Adds an `✕` at the right-hand end. Absent: the app removes the banner. */
33
+ onDismiss?: () => void
34
+ /** The dismiss button's accessible name. Defaults to "Dismiss". */
35
+ dismissLabel?: string
19
36
  className?: string
20
37
  }
21
38
 
@@ -26,10 +43,25 @@ const TONE_STYLES: Record<BannerTone, string> = {
26
43
  warning: 'bg-warning-muted text-warning-default',
27
44
  }
28
45
 
29
- export function Banner({ tone, children, className }: BannerProps) {
46
+ export function Banner({ tone, children, onDismiss, dismissLabel = 'Dismiss', className }: BannerProps) {
47
+ const role = tone === 'error' ? 'alert' : 'status'
48
+ // No dismiss, no row: every banner that predates the prop keeps the DOM and
49
+ // the height it had.
50
+ if (!onDismiss) {
51
+ return (
52
+ <div role={role} className={cn('px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
53
+ {children}
54
+ </div>
55
+ )
56
+ }
30
57
  return (
31
- <div role={tone === 'error' ? 'alert' : 'status'} className={cn('px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
32
- {children}
58
+ <div role={role} className={cn('flex items-center gap-3 px-4 py-2 text-body-2', TONE_STYLES[tone], className)}>
59
+ <span className="min-w-0 flex-1">{children}</span>
60
+ {/* `text-current` so the ✕ takes the tone's colour rather than the
61
+ muted grey an IconButton wears on a neutral surface. */}
62
+ <IconButton aria-label={dismissLabel} onClick={onDismiss} className="-mr-1 shrink-0 text-current hover:text-current">
63
+ <IconX size={16} stroke={1.5} />
64
+ </IconButton>
33
65
  </div>
34
66
  )
35
67
  }
@@ -35,13 +35,16 @@ import { Breadcrumb } from '@estiva-app/ui'
35
35
 
36
36
  <Breadcrumb
37
37
  items={[
38
- { label: 'Documents', href: '#/documents' },
39
- { label: 'Quarterly plan', href: '#/documents/12' },
38
+ { label: 'Documents', href: '/documents' },
39
+ { label: 'Quarterly plan', href: '/documents/12' },
40
40
  { label: 'DOC-12', mono: true },
41
41
  ]}
42
42
  />
43
43
  ```
44
44
 
45
+ - A crumb is a plain anchor. In a router app pass `onClick` per crumb and
46
+ navigate in place there — NavItem's rule — and keep the `href` a real
47
+ address, so the link can still be copied or opened in a new tab.
45
48
  - Truncation is re-measured as the trail resizes; the tooltip appears and
46
49
  disappears with the room the trail actually has. Give the trail a
47
50
  `min-w-0` container so it can truncate at all.
@@ -0,0 +1,44 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Breadcrumb page claims, pinned: a crumb with an href is a link
4
+ * (even the last one), the last crumb without one is where you are, the
5
+ * separators are never read aloud — and a router app can take the click,
6
+ * which is the one prop the trail lacked (ADOPTION S15, 2026-09-08).
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 { Breadcrumb } from './Breadcrumb'
12
+
13
+ afterEach(cleanup)
14
+
15
+ describe('Breadcrumb', () => {
16
+ it('a crumb with an href is a link, and the last one without is where you are', () => {
17
+ render(<Breadcrumb items={[{ label: 'Documents', href: '/documents' }, { label: 'DOC-12', mono: true }]} />)
18
+ expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
19
+ const here = screen.getByText('DOC-12')
20
+ expect(here.tagName).toBe('SPAN')
21
+ expect(here.getAttribute('aria-current')).toBe('page')
22
+ })
23
+
24
+ it('a last crumb with an href is still a link — a page may end its trail on somewhere to go', () => {
25
+ render(<Breadcrumb items={[{ label: 'Documents', href: '/documents' }, { label: 'Quarterly plan', href: '/documents/12' }]} />)
26
+ const last = screen.getByRole('link', { name: 'Quarterly plan' })
27
+ expect(last.getAttribute('href')).toBe('/documents/12')
28
+ expect(last.getAttribute('aria-current')).toBeNull()
29
+ })
30
+
31
+ it('a router app takes the click through onClick, and the href stays a real address', async () => {
32
+ const onClick = vi.fn((event: { preventDefault: () => void }) => event.preventDefault())
33
+ render(<Breadcrumb items={[{ label: 'Documents', href: '/documents', onClick }, { label: 'DOC-12' }]} />)
34
+ await userEvent.click(screen.getByRole('link', { name: 'Documents' }))
35
+ expect(onClick).toHaveBeenCalledTimes(1)
36
+ expect(screen.getByRole('link', { name: 'Documents' }).getAttribute('href')).toBe('/documents')
37
+ })
38
+
39
+ it('the separators are drawn but never read aloud', () => {
40
+ render(<Breadcrumb items={[{ label: 'Documents', href: '/documents' }, { label: 'DOC-12' }]} />)
41
+ expect(screen.getByRole('navigation', { name: 'Breadcrumb' }).textContent).toContain('/')
42
+ expect(screen.queryByText('/', { ignore: '[aria-hidden="true"]' })).toBeNull()
43
+ })
44
+ })
@@ -1,4 +1,4 @@
1
- import { Fragment, useCallback, useLayoutEffect, useRef, useState } from 'react'
1
+ import { Fragment, useCallback, useLayoutEffect, useRef, useState, type MouseEventHandler } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { WithTooltip } from './Tooltip'
4
4
 
@@ -15,10 +15,18 @@ import { WithTooltip } from './Tooltip'
15
15
  * (Katerina, 2026-09-01); one that fits shows nothing extra. Truncation is
16
16
  * re-measured when the trail resizes, so the tooltip appears and disappears
17
17
  * with the room the trail actually has.
18
+ *
19
+ * A crumb with an `href` is a plain anchor, so in a router app a click on it
20
+ * reloads the page. Such an app passes `onClick` per crumb and navigates in
21
+ * place there — NavItem's rule — and the `href` stays a real address so the
22
+ * link can still be copied or opened in a new tab. Ship's breadcrumbs were
23
+ * the last links reloading the whole app (ADOPTION S15, 2026-09-08).
18
24
  */
19
25
  export interface Crumb {
20
26
  label: string
21
27
  href?: string
28
+ /** A router app intercepts the click here. Called only on a crumb with an `href`. */
29
+ onClick?: MouseEventHandler<HTMLAnchorElement>
22
30
  /** Set the item in the mono face — a ref, an id. */
23
31
  mono?: boolean
24
32
  /** Quieter — a label that is not a place. */
@@ -73,7 +81,7 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
73
81
  labelRefs.current[index] = el
74
82
  }
75
83
  const crumb = item.href ? (
76
- <a ref={setLabelRef} href={item.href} className={cn(text, 'hover:text-text-primary')}>
84
+ <a ref={setLabelRef} href={item.href} onClick={item.onClick} className={cn(text, 'hover:text-text-primary')}>
77
85
  {item.label}
78
86
  </a>
79
87
  ) : (
@@ -0,0 +1,119 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * The buttons carry a ref, and a Base UI part can therefore *be* one.
4
+ *
5
+ * `Button` and `IconButton` typed their props as `ButtonHTMLAttributes`, which
6
+ * has no `ref`. React 19 hands `ref` to a function component as an ordinary
7
+ * prop, so it was already riding in on the spread and reaching the element —
8
+ * the type was the only thing stopping a caller. These tests pin both halves:
9
+ * that the ref lands, and that composition through `render` works, which is
10
+ * the shape all of stage 4 is built from (`Menu.Trigger`, `Dialog.Close`).
11
+ */
12
+ import { createRef } from 'react'
13
+ import { afterEach, describe, expect, it, vi } from 'vitest'
14
+ import { cleanup, render, screen } from '@testing-library/react'
15
+ import userEvent from '@testing-library/user-event'
16
+ import { Dialog } from '@base-ui/react/dialog'
17
+ import { IconSquareRounded } from '@tabler/icons-react'
18
+ import { Button } from './Button'
19
+ import { IconButton } from './IconButton'
20
+
21
+ afterEach(cleanup)
22
+
23
+ describe('the buttons take a ref', () => {
24
+ it('Button: the ref is the button element', () => {
25
+ const ref = createRef<HTMLButtonElement>()
26
+ render(<Button ref={ref}>Label</Button>)
27
+ expect(ref.current).toBe(screen.getByRole('button', { name: 'Label' }))
28
+ expect(ref.current?.tagName).toBe('BUTTON')
29
+ })
30
+
31
+ it('IconButton: the ref is the button element', () => {
32
+ const ref = createRef<HTMLButtonElement>()
33
+ render(
34
+ <IconButton ref={ref} aria-label="Item">
35
+ <IconSquareRounded size={16} stroke={1.5} />
36
+ </IconButton>,
37
+ )
38
+ expect(ref.current).toBe(screen.getByRole('button', { name: 'Item' }))
39
+ })
40
+ })
41
+
42
+ describe('a Base UI part can be one of our buttons', () => {
43
+ it('Dialog.Close renders as an IconButton and closes the dialog', async () => {
44
+ // This is the loose end stage 3 left: `DialogShell`'s ✕ called `onClose`
45
+ // by hand because `Close` as a render target needs exactly this.
46
+ const user = userEvent.setup()
47
+ const onOpenChange = vi.fn()
48
+ render(
49
+ <Dialog.Root open onOpenChange={onOpenChange}>
50
+ <Dialog.Portal>
51
+ <Dialog.Popup aria-label="A dialog">
52
+ <Dialog.Close
53
+ render={
54
+ <IconButton aria-label="Close">
55
+ <IconSquareRounded size={16} stroke={1.5} />
56
+ </IconButton>
57
+ }
58
+ />
59
+ </Dialog.Popup>
60
+ </Dialog.Portal>
61
+ </Dialog.Root>,
62
+ )
63
+ const close = screen.getByRole('button', { name: 'Close' })
64
+ // Still our button: the class list survives composition.
65
+ expect(close.className).toContain('rounded-lg')
66
+ await user.click(close)
67
+ expect(onOpenChange).toHaveBeenCalledTimes(1)
68
+ expect(onOpenChange.mock.calls[0][0]).toBe(false)
69
+ // Base UI says *why* it closed, which is what a hand-wired onClick cannot.
70
+ expect((onOpenChange.mock.calls[0][1] as { reason?: string })?.reason).toBe('close-press')
71
+ })
72
+
73
+ it('Dialog.Close renders as an IconButton THAT CARRIES A TOOLTIP', async () => {
74
+ /*
75
+ * The half stage 3 could not close. With the hand-written tooltip an
76
+ * IconButton with a `tooltip` returned `WithTooltip`'s wrapper `<div>` as
77
+ * its root, so `Close` composed onto the wrapper and the ✕ was never the
78
+ * part. On Base UI's Tooltip the trigger IS the button, so it is.
79
+ */
80
+ const user = userEvent.setup()
81
+ const onOpenChange = vi.fn()
82
+ render(
83
+ <Dialog.Root open onOpenChange={onOpenChange}>
84
+ <Dialog.Portal>
85
+ <Dialog.Popup aria-label="A dialog">
86
+ <Dialog.Close
87
+ render={
88
+ <IconButton aria-label="Close" tooltip="Close">
89
+ <IconSquareRounded size={16} stroke={1.5} />
90
+ </IconButton>
91
+ }
92
+ />
93
+ </Dialog.Popup>
94
+ </Dialog.Portal>
95
+ </Dialog.Root>,
96
+ )
97
+ const close = screen.getByRole('button', { name: 'Close' })
98
+ expect(close.tagName).toBe('BUTTON')
99
+ expect(close.className).toContain('rounded-lg')
100
+ await user.click(close)
101
+ expect(onOpenChange).toHaveBeenCalledTimes(1)
102
+ expect((onOpenChange.mock.calls[0][1] as { reason?: string })?.reason).toBe('close-press')
103
+ })
104
+ it('Dialog.Trigger renders as a Button and opens the dialog', async () => {
105
+ const user = userEvent.setup()
106
+ render(
107
+ <Dialog.Root>
108
+ <Dialog.Trigger render={<Button variant="primary">Open</Button>} />
109
+ <Dialog.Portal>
110
+ <Dialog.Popup aria-label="A dialog">Body</Dialog.Popup>
111
+ </Dialog.Portal>
112
+ </Dialog.Root>,
113
+ )
114
+ const trigger = screen.getByRole('button', { name: 'Open' })
115
+ expect(trigger.className).toContain('bg-accent-primary')
116
+ await user.click(trigger)
117
+ expect(await screen.findByRole('dialog', { name: 'A dialog' })).toBeTruthy()
118
+ })
119
+ })
package/src/Button.mdx CHANGED
@@ -53,11 +53,13 @@ import { IconPlus } from '@tabler/icons-react'
53
53
  passes through; the visible label is the accessible name, so no
54
54
  `aria-label` is needed.
55
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.
56
+ Tab, and shows the reason as a tooltip. Say why, every time; a bare
57
+ `disabled` is for the moment between a click and its answer. **The
58
+ reason shows on keyboard focus as well as on hover**, which is what
59
+ makes it worth writing: a reason only a mouse can read is not offered
60
+ to everyone. The button itself is the tooltip's trigger, so it wears no
61
+ wrapper — what a caller gets is the `<button>`, and a `Menu.Trigger` or
62
+ a `Dialog.Close` can be it.
61
63
 
62
64
  ## Keys
63
65
 
@@ -72,7 +72,8 @@ describe('Button', () => {
72
72
  expect(button.hasAttribute('disabled')).toBe(false)
73
73
  expect(screen.queryByRole('tooltip')).toBeNull()
74
74
  await user.hover(button)
75
- expect(screen.getByRole('tooltip').textContent).toBe('Sign in first')
75
+ // 300ms before it opens (D23) - it used to be instant.
76
+ expect((await screen.findByRole('tooltip')).textContent).toBe('Sign in first')
76
77
  await user.unhover(button)
77
78
  expect(screen.queryByRole('tooltip')).toBeNull()
78
79
  await user.click(button)