@estiva-app/ui 0.9.0 → 0.10.1

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 (154) 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 +888 -763
  58. package/dist/index.js.map +4 -4
  59. package/dist/triggerDisabled.d.ts +10 -0
  60. package/dist/triggerDisabled.d.ts.map +1 -0
  61. package/package.json +1 -1
  62. package/src/Avatar.mdx +17 -0
  63. package/src/Avatar.name.test.tsx +99 -0
  64. package/src/Avatar.tsx +25 -3
  65. package/src/AvatarGroup.tsx +4 -1
  66. package/src/Banner.mdx +10 -2
  67. package/src/Banner.stories.tsx +29 -0
  68. package/src/Banner.test.tsx +60 -0
  69. package/src/Banner.tsx +35 -3
  70. package/src/Breadcrumb.mdx +5 -2
  71. package/src/Breadcrumb.test.tsx +44 -0
  72. package/src/Breadcrumb.tsx +10 -2
  73. package/src/Button.compose.test.tsx +119 -0
  74. package/src/Button.mdx +7 -5
  75. package/src/Button.test.tsx +2 -1
  76. package/src/Button.tsx +21 -5
  77. package/src/Checkbox.tsx +3 -0
  78. package/src/Chip.tsx +3 -2
  79. package/src/ChipInput.tsx +4 -0
  80. package/src/ConfirmDialog.mdx +20 -1
  81. package/src/ConfirmDialog.test.tsx +109 -0
  82. package/src/ConfirmDialog.tsx +6 -0
  83. package/src/DialogShell.mdx +18 -4
  84. package/src/DialogShell.stories.tsx +56 -6
  85. package/src/DialogShell.test.tsx +184 -0
  86. package/src/DialogShell.tsx +108 -39
  87. package/src/EditableText.mdx +6 -1
  88. package/src/EditableText.test.tsx +156 -0
  89. package/src/EditableText.tsx +23 -5
  90. package/src/Field.mdx +23 -3
  91. package/src/Field.stories.tsx +35 -0
  92. package/src/Field.test.tsx +144 -26
  93. package/src/Field.tsx +94 -59
  94. package/src/IconButton.mdx +6 -2
  95. package/src/IconButton.test.tsx +5 -2
  96. package/src/IconButton.tsx +12 -6
  97. package/src/IdentityMenu.mdx +29 -6
  98. package/src/IdentityMenu.stories.tsx +41 -6
  99. package/src/IdentityMenu.tsx +74 -47
  100. package/src/Kbd.stories.tsx +3 -3
  101. package/src/Menu.mdx +88 -75
  102. package/src/Menu.stories.tsx +120 -52
  103. package/src/Menu.test.tsx +315 -0
  104. package/src/Menu.tsx +347 -251
  105. package/src/MenuItem.stories.tsx +135 -98
  106. package/src/NavItem.mdx +1 -1
  107. package/src/PersonTrigger.mdx +20 -14
  108. package/src/PersonTrigger.tsx +23 -1
  109. package/src/Popover.mdx +130 -0
  110. package/src/Popover.stories.tsx +222 -0
  111. package/src/Popover.test.tsx +188 -0
  112. package/src/Popover.tsx +156 -0
  113. package/src/PreviewCard.mdx +98 -0
  114. package/src/PreviewCard.stories.tsx +168 -0
  115. package/src/PreviewCard.test.tsx +83 -0
  116. package/src/PreviewCard.tsx +91 -0
  117. package/src/Rail.mdx +63 -0
  118. package/src/Rail.stories.tsx +81 -0
  119. package/src/Reaction.mdx +4 -2
  120. package/src/Reaction.tsx +4 -2
  121. package/src/ReactionPicker.mdx +85 -0
  122. package/src/ReactionPicker.stories.tsx +120 -0
  123. package/src/ReactionPicker.test.tsx +118 -0
  124. package/src/ReactionPicker.tsx +88 -0
  125. package/src/SearchInput.mdx +6 -2
  126. package/src/SearchInput.tsx +7 -1
  127. package/src/Select.mdx +16 -5
  128. package/src/Select.test.tsx +157 -0
  129. package/src/Select.tsx +89 -214
  130. package/src/Sidebar.mdx +6 -2
  131. package/src/Tabs.mdx +5 -0
  132. package/src/Tabs.test.tsx +15 -0
  133. package/src/Tabs.tsx +17 -2
  134. package/src/TextInput.mdx +8 -2
  135. package/src/TextInput.tsx +10 -7
  136. package/src/Textarea.mdx +6 -2
  137. package/src/Textarea.tsx +14 -9
  138. package/src/Toolbar.mdx +104 -0
  139. package/src/Toolbar.stories.tsx +142 -0
  140. package/src/Toolbar.test.tsx +253 -0
  141. package/src/Toolbar.tsx +158 -0
  142. package/src/Tooltip.mdx +43 -5
  143. package/src/Tooltip.stories.tsx +26 -0
  144. package/src/Tooltip.test.tsx +195 -0
  145. package/src/Tooltip.tsx +150 -48
  146. package/src/cn.ts +1 -1
  147. package/src/fit.test.ts +101 -0
  148. package/src/fit.ts +19 -63
  149. package/src/index.ts +6 -3
  150. package/src/triggerDisabled.ts +13 -0
  151. package/stories/Choosing.mdx +8 -3
  152. package/tailwind-preset.js +7 -0
  153. package/src/Menu.fit.test.ts +0 -90
  154. package/src/Select.fit.test.ts +0 -101
@@ -0,0 +1,142 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconArrowBackUp, IconArrowForwardUp, IconBold, IconItalic, IconLink, IconSquareRounded, IconUnderline } from '@tabler/icons-react'
3
+ import { useState } from 'react'
4
+ import { IconButton } from './IconButton'
5
+ import { Popover } from './Popover'
6
+ import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
7
+
8
+ /**
9
+ * A strip of controls that behaves as **one** control: Tab in, arrow keys
10
+ * along, Tab out.
11
+ *
12
+ * It draws the elevated box a floating strip needs — the same one a `Menu`
13
+ * draws — so a toolbar over a card, a paragraph or an image is separated from
14
+ * it without the caller drawing anything.
15
+ */
16
+ const meta = {
17
+ title: 'Primitives/Toolbar',
18
+ component: Toolbar,
19
+ args: { 'aria-label': 'Formatting', children: null },
20
+ argTypes: { children: { control: false } },
21
+ } satisfies Meta<typeof Toolbar>
22
+
23
+ export default meta
24
+ type Story = StoryObj<typeof meta>
25
+
26
+ const icon = <IconSquareRounded size={16} stroke={1.5} />
27
+
28
+ /** Four controls, one Tab stop. */
29
+ export const Default: Story = {
30
+ render: (args) => (
31
+ <Toolbar {...args}>
32
+ <ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
33
+ <ToolbarButton aria-label="Item two" tooltip="Item two">{icon}</ToolbarButton>
34
+ <ToolbarButton aria-label="Item three" tooltip="Item three">{icon}</ToolbarButton>
35
+ <ToolbarButton aria-label="Item four" tooltip="Item four">{icon}</ToolbarButton>
36
+ </Toolbar>
37
+ ),
38
+ }
39
+
40
+ /** Groups, divided. The separator carries the role as well as the hairline. */
41
+ export const Grouped: Story = {
42
+ parameters: { controls: { disable: true } },
43
+ render: () => (
44
+ <Toolbar aria-label="Formatting">
45
+ <ToolbarButton aria-label="Undo" tooltip="Undo" tooltipShortcut="Cmd+Z">
46
+ <IconArrowBackUp size={16} stroke={1.5} />
47
+ </ToolbarButton>
48
+ <ToolbarButton aria-label="Redo" tooltip="Redo" tooltipShortcut="Cmd+Shift+Z">
49
+ <IconArrowForwardUp size={16} stroke={1.5} />
50
+ </ToolbarButton>
51
+ <ToolbarSeparator />
52
+ <ToolbarButton aria-label="Bold" tooltip="Bold" tooltipShortcut="Cmd+B">
53
+ <IconBold size={16} stroke={1.5} />
54
+ </ToolbarButton>
55
+ <ToolbarButton aria-label="Italic" tooltip="Italic" tooltipShortcut="Cmd+I">
56
+ <IconItalic size={16} stroke={1.5} />
57
+ </ToolbarButton>
58
+ <ToolbarButton aria-label="Underline" tooltip="Underline" tooltipShortcut="Cmd+U">
59
+ <IconUnderline size={16} stroke={1.5} />
60
+ </ToolbarButton>
61
+ </Toolbar>
62
+ ),
63
+ }
64
+
65
+ /**
66
+ * A field in the strip. While it has focus the arrow keys are the caret's —
67
+ * which is what a field is for — and Tab resumes the walk. That is why a field
68
+ * in a toolbar is `ToolbarInput` rather than a `TextInput` dropped in the row.
69
+ */
70
+ export const WithAField: Story = {
71
+ parameters: { controls: { disable: true } },
72
+ render: function WithField() {
73
+ const [url, setUrl] = useState('')
74
+ return (
75
+ <Toolbar aria-label="Link">
76
+ <ToolbarButton aria-label="Link" tooltip="Link">
77
+ <IconLink size={16} stroke={1.5} />
78
+ </ToolbarButton>
79
+ <ToolbarSeparator />
80
+ <ToolbarInput value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Paste a link" aria-label="Link address" className="h-7 w-48" />
81
+ </Toolbar>
82
+ )
83
+ },
84
+ }
85
+
86
+ /**
87
+ * A control that cannot be used **keeps its place in the walk**. A strip whose
88
+ * controls come and go from the arrow keys as their state changes is a strip
89
+ * you cannot learn — and a `disabledReason` you cannot reach is a reason
90
+ * nobody reads.
91
+ */
92
+ export const WithADisabledControl: Story = {
93
+ parameters: { controls: { disable: true } },
94
+ render: () => (
95
+ <Toolbar aria-label="Formatting">
96
+ <ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
97
+ <ToolbarButton aria-label="Item two" disabledReason="Available once there is a selection">{icon}</ToolbarButton>
98
+ <ToolbarButton aria-label="Item three" tooltip="Item three">{icon}</ToolbarButton>
99
+ </Toolbar>
100
+ ),
101
+ }
102
+
103
+ /** Down rather than across: ↑ and ↓ walk it. */
104
+ export const Vertical: Story = {
105
+ parameters: { controls: { disable: true } },
106
+ render: () => (
107
+ <Toolbar aria-label="Formatting" orientation="vertical">
108
+ <ToolbarButton aria-label="Item one" tooltip="Item one" tooltipPlacement="bottom">{icon}</ToolbarButton>
109
+ <ToolbarButton aria-label="Item two" tooltip="Item two" tooltipPlacement="bottom">{icon}</ToolbarButton>
110
+ <ToolbarButton aria-label="Item three" tooltip="Item three" tooltipPlacement="bottom">{icon}</ToolbarButton>
111
+ </Toolbar>
112
+ ),
113
+ }
114
+
115
+ /**
116
+ * **Inside something that already draws a box** — a `Popover`, a dialog, a
117
+ * card's own panel — the strip drops its own with `surface={false}`. Two
118
+ * boxes inside each other is the tell.
119
+ */
120
+ export const OnAnExistingSurface: Story = {
121
+ parameters: { controls: { disable: true }, layout: 'fullscreen' },
122
+ render: () => (
123
+ <div className="flex h-[220px] w-full items-end justify-center pb-4">
124
+ <Popover
125
+ /* An `IconButton`, not a `ToolbarButton`: the trigger stands outside the
126
+ strip, and a toolbar part outside a `Toolbar` throws. */
127
+ trigger={<IconButton aria-label="Open" tooltip="Open">{icon}</IconButton>}
128
+ ariaLabel="Formatting"
129
+ /* Above the control that opened it: a strip acts on what is under it.
130
+ `side` is the preference; Base UI flips it when there is no room. */
131
+ side="top"
132
+ className="w-auto min-w-0 p-1"
133
+ >
134
+ <Toolbar aria-label="Formatting" surface={false}>
135
+ <ToolbarButton aria-label="Item one" tooltip="Item one">{icon}</ToolbarButton>
136
+ <ToolbarButton aria-label="Item two" tooltip="Item two">{icon}</ToolbarButton>
137
+ <ToolbarButton aria-label="Item three" tooltip="Item three">{icon}</ToolbarButton>
138
+ </Toolbar>
139
+ </Popover>
140
+ </div>
141
+ ),
142
+ }
@@ -0,0 +1,253 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Toolbar page claims, pinned — and the reason the component exists
4
+ * is the first test: a strip of controls is one Tab stop, not one per button.
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
+ import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
11
+ import { Menu, MenuItem } from './Menu'
12
+ import { Popover } from './Popover'
13
+
14
+ afterEach(cleanup)
15
+
16
+ const dot = <span aria-hidden="true">·</span>
17
+
18
+ function Strip({ label = 'Formatting' }: { label?: string }) {
19
+ return (
20
+ <>
21
+ <button type="button">Before</button>
22
+ <Toolbar aria-label={label}>
23
+ <ToolbarButton aria-label="One">{dot}</ToolbarButton>
24
+ <ToolbarButton aria-label="Two">{dot}</ToolbarButton>
25
+ <ToolbarButton aria-label="Three">{dot}</ToolbarButton>
26
+ </Toolbar>
27
+ <button type="button">After</button>
28
+ </>
29
+ )
30
+ }
31
+
32
+ const focused = () => document.activeElement?.getAttribute('aria-label') ?? document.activeElement?.textContent
33
+
34
+ describe('Toolbar', () => {
35
+ it('is a named toolbar', () => {
36
+ render(<Strip />)
37
+ expect(screen.getByRole('toolbar', { name: 'Formatting' })).toBeTruthy()
38
+ })
39
+
40
+ /** The whole point. Three buttons, one stop. */
41
+ it('is one Tab stop, whatever it holds', async () => {
42
+ const user = userEvent.setup()
43
+ render(<Strip />)
44
+ screen.getByRole('button', { name: 'Before' }).focus()
45
+ await user.tab()
46
+ expect(focused()).toBe('One')
47
+ await user.tab()
48
+ expect(focused()).toBe('After')
49
+ })
50
+
51
+ it('and coming back lands on the toolbar, not inside it', async () => {
52
+ const user = userEvent.setup()
53
+ render(<Strip />)
54
+ screen.getByRole('button', { name: 'After' }).focus()
55
+ await user.tab({ shift: true })
56
+ expect(focused()).toBe('One')
57
+ })
58
+
59
+ it('the arrow keys walk it, and wrap', async () => {
60
+ const user = userEvent.setup()
61
+ render(<Strip />)
62
+ screen.getByRole('button', { name: 'One' }).focus()
63
+ await user.keyboard('{ArrowRight}')
64
+ expect(focused()).toBe('Two')
65
+ await user.keyboard('{ArrowRight}{ArrowRight}')
66
+ expect(focused()).toBe('One')
67
+ await user.keyboard('{ArrowLeft}')
68
+ expect(focused()).toBe('Three')
69
+ })
70
+
71
+ /**
72
+ * The gap, pinned as a gap. Base UI's composite implements Home and End
73
+ * behind `enableHomeAndEndKeys`, and `Toolbar.Root` does not pass it — so
74
+ * they do nothing here, the page does not claim them, and this test fails
75
+ * the day that changes.
76
+ */
77
+ it('Home and End do nothing, which is Base UI’s choice and is stated', async () => {
78
+ const user = userEvent.setup()
79
+ render(<Strip />)
80
+ screen.getByRole('button', { name: 'Two' }).focus()
81
+ await user.keyboard('{End}')
82
+ expect(focused()).toBe('Two')
83
+ await user.keyboard('{Home}')
84
+ expect(focused()).toBe('Two')
85
+ })
86
+
87
+ it('a vertical toolbar walks with ↑ and ↓', async () => {
88
+ const user = userEvent.setup()
89
+ render(
90
+ <Toolbar aria-label="Formatting" orientation="vertical">
91
+ <ToolbarButton aria-label="One">{dot}</ToolbarButton>
92
+ <ToolbarButton aria-label="Two">{dot}</ToolbarButton>
93
+ </Toolbar>,
94
+ )
95
+ expect(screen.getByRole('toolbar').getAttribute('aria-orientation')).toBe('vertical')
96
+ screen.getByRole('button', { name: 'One' }).focus()
97
+ await user.keyboard('{ArrowDown}')
98
+ expect(focused()).toBe('Two')
99
+ })
100
+
101
+ it('a button still runs its onClick, and is still an IconButton', async () => {
102
+ const user = userEvent.setup()
103
+ const onClick = vi.fn()
104
+ render(
105
+ <Toolbar aria-label="Formatting">
106
+ <ToolbarButton aria-label="One" onClick={onClick}>{dot}</ToolbarButton>
107
+ </Toolbar>,
108
+ )
109
+ const button = screen.getByRole('button', { name: 'One' })
110
+ expect(button.tagName).toBe('BUTTON')
111
+ await user.click(button)
112
+ expect(onClick).toHaveBeenCalledTimes(1)
113
+ })
114
+
115
+ /**
116
+ * A strip whose controls come and go from the arrow keys as their state
117
+ * changes is a strip you cannot learn — and a `disabledReason` you cannot
118
+ * reach is a reason nobody reads.
119
+ */
120
+ it('a disabled control keeps its place in the walk', async () => {
121
+ const user = userEvent.setup()
122
+ render(
123
+ <Toolbar aria-label="Formatting">
124
+ <ToolbarButton aria-label="One">{dot}</ToolbarButton>
125
+ <ToolbarButton aria-label="Two" disabledReason="Not yet">{dot}</ToolbarButton>
126
+ <ToolbarButton aria-label="Three">{dot}</ToolbarButton>
127
+ </Toolbar>,
128
+ )
129
+ screen.getByRole('button', { name: 'One' }).focus()
130
+ await user.keyboard('{ArrowRight}')
131
+ expect(focused()).toBe('Two')
132
+ expect(screen.getByRole('button', { name: 'Two' }).getAttribute('aria-disabled')).toBe('true')
133
+ })
134
+
135
+ /** The box, which a floating strip needs and Peek had drawn twice. */
136
+ it('draws the elevated box, and drops it on request', () => {
137
+ const withBox = render(<Toolbar aria-label="Formatting"><ToolbarButton aria-label="One">{dot}</ToolbarButton></Toolbar>)
138
+ const panel = withBox.container.firstElementChild as HTMLElement
139
+ expect(panel.className).toContain('bg-bg-elevated')
140
+ expect(panel.querySelector('[role="toolbar"]')).toBeTruthy()
141
+ // The strip stays a row inside a panel that is a column for a menu's rows.
142
+ expect(panel.querySelector('[role="toolbar"]')!.className).not.toContain('flex-col')
143
+ withBox.unmount()
144
+
145
+ const bare = render(<Toolbar aria-label="Formatting" surface={false}><ToolbarButton aria-label="One">{dot}</ToolbarButton></Toolbar>)
146
+ expect((bare.container.firstElementChild as HTMLElement).getAttribute('role')).toBe('toolbar')
147
+ })
148
+
149
+ it('a separator is a separator, not just a hairline', () => {
150
+ render(
151
+ <Toolbar aria-label="Formatting">
152
+ <ToolbarButton aria-label="One">{dot}</ToolbarButton>
153
+ <ToolbarSeparator />
154
+ <ToolbarButton aria-label="Two">{dot}</ToolbarButton>
155
+ </Toolbar>,
156
+ )
157
+ expect(screen.getByRole('separator')).toBeTruthy()
158
+ })
159
+
160
+ /** While a field has focus the arrow keys are the caret's, which is what a
161
+ * field is for; Tab resumes the walk. */
162
+ it('a field in the strip keeps the arrow keys for its caret', async () => {
163
+ const user = userEvent.setup()
164
+ render(
165
+ <Toolbar aria-label="Link">
166
+ <ToolbarButton aria-label="One">{dot}</ToolbarButton>
167
+ <ToolbarInput aria-label="Link address" defaultValue="abc" />
168
+ </Toolbar>,
169
+ )
170
+ const field = screen.getByRole('textbox', { name: 'Link address' }) as HTMLInputElement
171
+ field.focus()
172
+ field.setSelectionRange(3, 3)
173
+ await user.keyboard('{ArrowLeft}')
174
+ expect(document.activeElement).toBe(field)
175
+ expect(field.selectionStart).toBe(2)
176
+ })
177
+ })
178
+
179
+ describe('a loose row, for comparison', () => {
180
+ /** The measurement the component exists to change: the same three buttons
181
+ * outside a toolbar are three Tab stops. */
182
+ it('is one Tab stop per button', async () => {
183
+ const user = userEvent.setup()
184
+ render(
185
+ <>
186
+ <button type="button">Before</button>
187
+ <div className="flex">
188
+ <IconButton aria-label="One">{dot}</IconButton>
189
+ <IconButton aria-label="Two">{dot}</IconButton>
190
+ <IconButton aria-label="Three">{dot}</IconButton>
191
+ </div>
192
+ <button type="button">After</button>
193
+ </>,
194
+ )
195
+ screen.getByRole('button', { name: 'Before' }).focus()
196
+ const walk: (string | null | undefined)[] = []
197
+ for (let i = 0; i < 4; i++) { await user.tab(); walk.push(focused()) }
198
+ expect(walk).toEqual(['One', 'Two', 'Three', 'After'])
199
+ })
200
+ })
201
+
202
+ describe('a ToolbarButton with a reason, as a trigger', () => {
203
+ /*
204
+ Found by Ship's adoption (2026-09-08, Finding 39): the message's tools put
205
+ "React" (a Popover trigger) and "…" (a Menu trigger) on one strip, and with
206
+ a `disabledReason` both came out `aria-disabled="false"`. The part that
207
+ renders the trigger writes its own disabled state over the button's.
208
+ */
209
+ it('stays disabled, reachable, and closed as a Popover trigger', async () => {
210
+ const user = userEvent.setup()
211
+ render(
212
+ <Toolbar aria-label="Tools">
213
+ <Popover
214
+ ariaLabel="Reactions"
215
+ trigger={
216
+ <ToolbarButton aria-label="React" disabledReason="Sign in to react.">
217
+ {dot}
218
+ </ToolbarButton>
219
+ }
220
+ >
221
+ <p>the picker</p>
222
+ </Popover>
223
+ </Toolbar>,
224
+ )
225
+ const button = screen.getByRole('button', { name: 'React' })
226
+ expect(button.getAttribute('aria-disabled')).toBe('true')
227
+ expect(button.getAttribute('tabindex')).toBe('0')
228
+ await user.click(button)
229
+ expect(screen.queryByRole('dialog')).toBeNull()
230
+ })
231
+
232
+ it('stays disabled, reachable, and closed as a Menu trigger', async () => {
233
+ const user = userEvent.setup()
234
+ render(
235
+ <Toolbar aria-label="Tools">
236
+ <Menu
237
+ trigger={
238
+ <ToolbarButton aria-label="More" disabledReason="Sign in first.">
239
+ {dot}
240
+ </ToolbarButton>
241
+ }
242
+ >
243
+ <MenuItem label="Delete" onClick={() => {}} />
244
+ </Menu>
245
+ </Toolbar>,
246
+ )
247
+ const button = screen.getByRole('button', { name: 'More' })
248
+ expect(button.getAttribute('aria-disabled')).toBe('true')
249
+ expect(button.getAttribute('tabindex')).toBe('0')
250
+ await user.click(button)
251
+ expect(screen.queryByRole('menu')).toBeNull()
252
+ })
253
+ })
@@ -0,0 +1,158 @@
1
+ import type { ReactNode, Ref } from 'react'
2
+ import { Toolbar as BaseToolbar } from '@base-ui/react/toolbar'
3
+ import { cn } from './cn'
4
+ import { IconButton, type IconButtonProps } from './IconButton'
5
+ import { MenuPanel } from './Menu'
6
+ import { TextInput, type TextInputProps } from './TextInput'
7
+
8
+ /**
9
+ * A strip of controls that behaves as **one** control.
10
+ *
11
+ * New at Katerina's request, 2026-09-08, pulled forward from stage 6.
12
+ *
13
+ * **It exists because every strip in the suite is as many Tab stops as it has
14
+ * buttons.** Ship's reaction row, Peek's composer strip and the editor's
15
+ * formatting strip are each a hand-rolled `<div class="flex">` of
16
+ * `IconButton`s: eight buttons, eight stops, and no way to get past them but
17
+ * through them. A toolbar is one stop — Tab in, arrow keys along, Tab out —
18
+ * which is what the `toolbar` role means and what Base UI's part implements.
19
+ *
20
+ * What it is not: a `Menu` (that is a list of *verbs* you open and choose
21
+ * from, and it closes when you do) or a row of unrelated buttons (a form's
22
+ * Cancel and Save are two separate answers, and each deserves its own stop).
23
+ * A toolbar is a set of things you do *to* something that is still there.
24
+ *
25
+ * **It draws the box.** A toolbar floats over what it acts on — a card, a
26
+ * paragraph, an image — so it carries the elevated surface that separates it
27
+ * from that: the same `MenuPanel` a `Menu` draws, because a floating strip and
28
+ * a floating list are the same box. Peek had built this twice and the two had
29
+ * already drifted — `ConversationQuickMenu` is `rounded-sm` with `shadow-sm`
30
+ * and a subtle border, `ReactionPicker` `rounded-lg` with `shadow-lg` and a
31
+ * default one. There is one box now. `surface={false}` for a strip inside
32
+ * something that already draws it.
33
+ *
34
+ * **The gap, stated: Home and End do nothing.** Base UI's composite has them
35
+ * and its `Toolbar` does not switch them on (`enableHomeAndEndKeys`, read in
36
+ * `useCompositeRoot`, not passed by `ToolbarRoot`) — so this page does not
37
+ * claim them. The arrow keys wrap, which reaches both ends in one press of a
38
+ * strip this size.
39
+ */
40
+ export interface ToolbarProps {
41
+ /**
42
+ * Names the strip for assistive tech — "Formatting", "Reactions". A toolbar
43
+ * is a landmark-ish grouping and is announced as a bare "toolbar" without
44
+ * one, which says nothing when a page has two.
45
+ */
46
+ 'aria-label': string
47
+ /** `horizontal` walks with ← →, `vertical` with ↑ ↓. Default horizontal. */
48
+ orientation?: 'horizontal' | 'vertical'
49
+ /** Whether the arrow keys wrap at the ends. Default true, as Base UI's. */
50
+ loopFocus?: boolean
51
+ /**
52
+ * The elevated box around the strip. **On by default** — a toolbar floats,
53
+ * and this is what floating looks like here.
54
+ *
55
+ * Off for a strip inside a surface that already draws one: a `Popover`, a
56
+ * dialog, a card's own panel. Two boxes inside each other is the tell.
57
+ */
58
+ surface?: boolean
59
+ children: ReactNode
60
+ /** The row's own layout — its gap, its padding, its wrapping. */
61
+ className?: string
62
+ }
63
+
64
+ export function Toolbar({ 'aria-label': ariaLabel, orientation = 'horizontal', loopFocus = true, surface = true, children, className }: ToolbarProps) {
65
+ const strip = (
66
+ <BaseToolbar.Root
67
+ aria-label={ariaLabel}
68
+ orientation={orientation}
69
+ loopFocus={loopFocus}
70
+ className={cn('flex items-center gap-1', orientation === 'vertical' && 'flex-col', className)}
71
+ >
72
+ {children}
73
+ </BaseToolbar.Root>
74
+ )
75
+ if (!surface) return strip
76
+ /*
77
+ The box WRAPS the strip rather than being composed onto it. `MenuPanel` is
78
+ a flex COLUMN — right for a menu's rows, wrong for a row of controls — and
79
+ Tailwind emits `flex-col` after `flex-row`, so a merged class list would
80
+ stand the toolbar on its end whatever order the classes arrived in. One
81
+ extra element, and the box keeps its single definition.
82
+
83
+ `p-1` is the tighter of Peek's two paddings and the right one for a strip
84
+ of 24px controls; `MenuPanel`'s own `p-2` is a menu's, where the rows run
85
+ the full width.
86
+ */
87
+ return <MenuPanel className="w-fit p-1">{strip}</MenuPanel>
88
+ }
89
+
90
+ /**
91
+ * One control in the strip. It is this package's `IconButton` — the same
92
+ * square, the same variants, the same `tooltip` and `disabledReason` — joined
93
+ * to the toolbar's roving focus.
94
+ *
95
+ * A disabled button **keeps its place in the walk**: a strip whose controls
96
+ * come and go from the arrow keys as their state changes is a strip you cannot
97
+ * learn. It is also what lets a `disabledReason` be read.
98
+ *
99
+ * **It must be inside a `Toolbar`.** Base UI throws otherwise —
100
+ * "ToolbarRootContext is missing" — because a part with no strip has no walk
101
+ * to join. A control beside a strip is an `IconButton`.
102
+ */
103
+ export interface ToolbarButtonProps extends IconButtonProps {
104
+ ref?: Ref<HTMLButtonElement>
105
+ }
106
+
107
+ export function ToolbarButton({ ref, disabled, disabledReason, ...props }: ToolbarButtonProps) {
108
+ return (
109
+ <BaseToolbar.Button
110
+ ref={ref}
111
+ /*
112
+ The part has to be told, not only the button inside it. The toolbar
113
+ keeps its own map of which items are disabled — it is what decides
114
+ where an arrow key lands — and a `Toolbar.Button` that renders a
115
+ disabled `IconButton` without saying so leaves the two disagreeing:
116
+ measured, the element came out `aria-disabled="false"` over a button
117
+ that was disabled.
118
+ */
119
+ disabled={disabled || !!disabledReason}
120
+ /* Keep it in the walk either way. A strip whose controls come and go
121
+ from the arrow keys as their state changes is a strip you cannot
122
+ learn, and a `disabledReason` you cannot reach is a reason nobody
123
+ reads. */
124
+ focusableWhenDisabled
125
+ render={<IconButton disabled={disabled} disabledReason={disabledReason} {...props} />}
126
+ />
127
+ )
128
+ }
129
+
130
+ /**
131
+ * A text field inside the strip — the link editor in a selection toolbar.
132
+ *
133
+ * The arrow keys belong to the field while it has focus, which is the whole
134
+ * point: ← and → move the caret. The toolbar's walk resumes at Tab. That is
135
+ * Base UI's `Toolbar.Input`, and it is why a field in a toolbar cannot just be
136
+ * a `TextInput` dropped in the row.
137
+ */
138
+ export type ToolbarInputProps = TextInputProps
139
+
140
+ export function ToolbarInput(props: ToolbarInputProps) {
141
+ return <BaseToolbar.Input render={<TextInput />} {...props} />
142
+ }
143
+
144
+ /**
145
+ * The hairline between two groups of controls. It is `Divider`'s rule and
146
+ * `Toolbar.Separator`'s role, so a screen reader hears the grouping a sighted
147
+ * reader sees.
148
+ */
149
+ export function ToolbarSeparator({ className }: { className?: string }) {
150
+ return (
151
+ <BaseToolbar.Separator
152
+ orientation="vertical"
153
+ /* `shrink-0` on a hairline in a flex row, always: without it a 1px rule
154
+ renders at 0px and nobody notices (the package's own trap table). */
155
+ className={cn('mx-1 h-4 w-px shrink-0 bg-border-default', className)}
156
+ />
157
+ )
158
+ }
package/src/Tooltip.mdx CHANGED
@@ -6,8 +6,7 @@ import * as TooltipStories from './Tooltip.stories'
6
6
  # Tooltip
7
7
 
8
8
  A 30px elevated pill with a caption, and **WithTooltip**, the wrapper that
9
- shows it above or below a trigger on hover portalled, fixed to the
10
- viewport, kept 8px inside its edges.
9
+ shows it beside a trigger on hover, and on keyboard focus.
11
10
 
12
11
  <Canvas of={TooltipStories.OnHoverTop} />
13
12
 
@@ -32,7 +31,7 @@ viewport, kept 8px inside its edges.
32
31
  - Anything the person must read to proceed → put it in the surface, not
33
32
  behind a hover.
34
33
  - Anything interactive inside → **Menu** or **DialogShell**; a tooltip is
35
- `pointer-events: none` by design.
34
+ `pointer-events: none` by design and cannot be pointed at.
36
35
 
37
36
  ## How
38
37
 
@@ -48,13 +47,52 @@ import { WithTooltip } from '@estiva-app/ui'
48
47
  shrinks nothing, so a wrapped block collapses to its content width.
49
48
  - A truncating label keeps truncating if you pass
50
49
  `wrapperClassName="min-w-0 shrink"`.
51
- - It shows on **hover only** there is no focus or touch trigger. Don't
52
- put anything behind it that a keyboard user must reach.
50
+ - **`Button` and `IconButton` need no wrapper**: pass `tooltip` (or
51
+ `disabledReason`) and the button itself becomes the trigger. That is what
52
+ lets a `Dialog.Close` or a `Menu.Trigger` be one of them — the component's
53
+ root is the `<button>`, with nothing in between.
53
54
  - `shortcut` renders, it does not format. A modifier is called Cmd on Apple
54
55
  platforms and Ctrl elsewhere, and only the caller knows which it is
55
56
  looking at — so pass the finished label. `IconButton` forwards its own
56
57
  `tooltipShortcut` here.
57
58
 
59
+ ### The delay, and why a toolbar only pauses once
60
+
61
+ A tooltip waits **300ms** before it opens. Mount **one `TooltipProvider` at
62
+ the top of the app** and every tooltip below it shares that delay: the first
63
+ waits, and while the group stays warm the neighbours open as the pointer
64
+ arrives — so reading along a row of icon buttons is one pause, not one per
65
+ button. Without the provider nothing breaks; each simply waits its own 300ms.
66
+
67
+ ```tsx
68
+ import { TooltipProvider } from '@estiva-app/ui'
69
+
70
+ <TooltipProvider>
71
+ <App />
72
+ </TooltipProvider>
73
+ ```
74
+
75
+ <Canvas of={TooltipStories.InAToolbar} />
76
+
77
+ ### The motion
78
+
79
+ It fades in over **120ms** and travels **4px** away from the trigger — up
80
+ from below when it stands above a control, down from above when it hangs
81
+ beneath one — and fades out over **80ms**. Moving between triggers inside a
82
+ warm group skips the animation entirely, so a toolbar sweep does not flicker.
83
+ `prefers-reduced-motion` removes it.
84
+
85
+ ## Keys
86
+
87
+ | Input | What happens |
88
+ |---|---|
89
+ | pointer rests on the trigger | opens after 300ms — instantly if a neighbour's tooltip was just up |
90
+ | pointer leaves | closes |
91
+ | Tab onto the control | opens, with no delay |
92
+ | Tab away | closes |
93
+ | Escape while it is up | closes |
94
+ | a click on the trigger | closes it — the tooltip has said its piece |
95
+
58
96
  ## Props
59
97
 
60
98
  <Controls of={TooltipStories.Default} />
@@ -1,5 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconArchive, IconPin, IconTrash } from '@tabler/icons-react'
2
3
  import { Button } from './Button'
4
+ import { IconButton } from './IconButton'
3
5
  import { Tooltip, WithTooltip } from './Tooltip'
4
6
 
5
7
  const meta = {
@@ -60,3 +62,27 @@ export const OnADisabledControl: Story = {
60
62
  </WithTooltip>
61
63
  ),
62
64
  }
65
+
66
+
67
+ /**
68
+ * A row of controls, which is where the shared delay shows itself: the first
69
+ * tooltip waits 300ms, and while the group stays warm the neighbours open as
70
+ * the pointer arrives — and the pill moves between them without re-animating.
71
+ * An app gets this by mounting one `TooltipProvider` at its root.
72
+ */
73
+ export const InAToolbar: Story = {
74
+ parameters: { controls: { disable: true } },
75
+ render: () => (
76
+ <div className="flex items-center gap-1 rounded-lg border border-border-default bg-bg-elevated p-1">
77
+ <IconButton aria-label="Pin" tooltip="Pin">
78
+ <IconPin size={16} stroke={1.5} />
79
+ </IconButton>
80
+ <IconButton aria-label="Archive" tooltip="Archive">
81
+ <IconArchive size={16} stroke={1.5} />
82
+ </IconButton>
83
+ <IconButton aria-label="Delete" tooltip="Delete" tooltipShortcut="Del">
84
+ <IconTrash size={16} stroke={1.5} />
85
+ </IconButton>
86
+ </div>
87
+ ),
88
+ }