@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
@@ -0,0 +1,315 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Menu page claims, pinned.
4
+ *
5
+ * The shell had no component test — only `Menu.fit.test.ts`, which tested the
6
+ * pure geometry rather than the menu. Placement is Floating UI's now and is
7
+ * measured in a browser (jsdom lays nothing out); what is testable here is the
8
+ * behaviour, and stage 4 is where most of it started existing.
9
+ */
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
12
+ import userEvent from '@testing-library/user-event'
13
+ import { Button } from './Button'
14
+ import { Menu, MenuItem, MenuPanel, MenuSection, MenuSub } from './Menu'
15
+
16
+ afterEach(cleanup)
17
+
18
+ /** A trigger and the menu it opens — which is now the whole of a caller's
19
+ * code: no open state, no anchor, no dismiss. */
20
+ function Basic({ onOpenChange }: { onOpenChange?: (open: boolean) => void }) {
21
+ return (
22
+ <Menu trigger={<Button variant="outlined">Trigger</Button>} onOpenChange={onOpenChange}>
23
+ <MenuItem label="Rename" onClick={() => {}} />
24
+ <MenuItem label="Duplicate" onClick={() => {}} />
25
+ <MenuItem label="Delete" destructive onClick={() => {}} />
26
+ </Menu>
27
+ )
28
+ }
29
+
30
+ describe('Menu', () => {
31
+ it('opens from its own trigger, and the rows are menu items', async () => {
32
+ const user = userEvent.setup()
33
+ render(<Basic />)
34
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
35
+ expect(await screen.findByRole('menu')).toBeTruthy()
36
+ const items = screen.getAllByRole('menuitem')
37
+ expect(items).toHaveLength(3)
38
+ // `render` keeps the <button> the design was drawn with; the part would
39
+ // otherwise draw a <div>.
40
+ expect(items.every((i) => i.tagName === 'BUTTON')).toBe(true)
41
+ })
42
+
43
+ it('the trigger says whether its menu is open', async () => {
44
+ const user = userEvent.setup()
45
+ render(<Basic />)
46
+ const trigger = screen.getByRole('button', { name: 'Trigger' })
47
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
48
+ await user.click(trigger)
49
+ await screen.findByRole('menu')
50
+ expect(trigger.getAttribute('aria-expanded')).toBe('true')
51
+ })
52
+
53
+ it('portals: the menu is not inside the element that rendered it', async () => {
54
+ const user = userEvent.setup()
55
+ const { container } = render(<Basic />)
56
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
57
+ const menu = await screen.findByRole('menu')
58
+ expect(container.contains(menu)).toBe(false)
59
+ })
60
+
61
+ it('closes on Escape and gives focus back to the trigger', async () => {
62
+ const user = userEvent.setup()
63
+ render(<Basic />)
64
+ const trigger = screen.getByRole('button', { name: 'Trigger' })
65
+ await user.click(trigger)
66
+ await screen.findByRole('menu')
67
+ await user.keyboard('{Escape}')
68
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
69
+ expect(document.activeElement).toBe(trigger)
70
+ })
71
+
72
+ it('closes on a press outside', async () => {
73
+ const user = userEvent.setup()
74
+ render(
75
+ <>
76
+ <Basic />
77
+ <button type="button">Elsewhere</button>
78
+ </>,
79
+ )
80
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
81
+ await screen.findByRole('menu')
82
+ await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
83
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
84
+ })
85
+
86
+ /**
87
+ * The trigger is the menu's own, so a press on it toggles. This used to be a
88
+ * trap: the press dismissed the menu and the caller's `onClick` reopened it
89
+ * in the same gesture, and the workaround was `onMouseDown` with
90
+ * `stopPropagation`. There is no caller `onClick` any more.
91
+ */
92
+ it('a second press on the trigger closes it', async () => {
93
+ const user = userEvent.setup()
94
+ render(<Basic />)
95
+ const trigger = screen.getByRole('button', { name: 'Trigger' })
96
+ await user.click(trigger)
97
+ await screen.findByRole('menu')
98
+ await user.click(trigger)
99
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
100
+ })
101
+
102
+ it('reports opening and closing to a caller that asks', async () => {
103
+ const user = userEvent.setup()
104
+ const onOpenChange = vi.fn()
105
+ render(<Basic onOpenChange={onOpenChange} />)
106
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
107
+ await screen.findByRole('menu')
108
+ expect(onOpenChange).toHaveBeenCalledWith(true)
109
+ await user.keyboard('{Escape}')
110
+ expect(onOpenChange).toHaveBeenCalledWith(false)
111
+ })
112
+
113
+ it('choosing a row runs its handler and closes the menu', async () => {
114
+ const user = userEvent.setup()
115
+ const onPick = vi.fn()
116
+ render(
117
+ <Menu trigger={<Button variant="outlined">Trigger</Button>}>
118
+ <MenuItem label="Rename" onClick={onPick} />
119
+ </Menu>,
120
+ )
121
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
122
+ await user.click(await screen.findByRole('menuitem', { name: 'Rename' }))
123
+ expect(onPick).toHaveBeenCalledTimes(1)
124
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
125
+ })
126
+
127
+ it('a section is a labelled group, so its rows are announced together', async () => {
128
+ const user = userEvent.setup()
129
+ render(
130
+ <Menu trigger={<Button variant="outlined">Trigger</Button>}>
131
+ <MenuSection label="Section">
132
+ <MenuItem label="Item one" onClick={() => {}} />
133
+ <MenuItem label="Item two" onClick={() => {}} />
134
+ </MenuSection>
135
+ </Menu>,
136
+ )
137
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
138
+ const group = await screen.findByRole('group')
139
+ expect(group.textContent).toContain('Section')
140
+ expect(group.querySelectorAll('[role="menuitem"]')).toHaveLength(2)
141
+ })
142
+
143
+ /**
144
+ * The Keys table, pinned. Every row of it is Base UI's, and none of it
145
+ * existed before stage 4 — which is exactly why it is tested here: a
146
+ * behaviour nobody wrote is a behaviour nobody notices losing.
147
+ *
148
+ * `data-highlighted` is the assertion because it is what the row's fill
149
+ * keys off, so a test that passes and a menu that looks wrong cannot come
150
+ * apart.
151
+ */
152
+ const highlighted = () => screen.getAllByRole('menuitem').find((i) => i.hasAttribute('data-highlighted'))?.textContent
153
+
154
+ it('opens from the keyboard with the first row highlighted', async () => {
155
+ const user = userEvent.setup()
156
+ render(<Basic />)
157
+ screen.getByRole('button', { name: 'Trigger' }).focus()
158
+ await user.keyboard('{Enter}')
159
+ await screen.findByRole('menu')
160
+ expect(highlighted()).toBe('Rename')
161
+ })
162
+
163
+ it('the arrow keys walk the rows and wrap at both ends', async () => {
164
+ const user = userEvent.setup()
165
+ render(<Basic />)
166
+ screen.getByRole('button', { name: 'Trigger' }).focus()
167
+ await user.keyboard('{Enter}')
168
+ await screen.findByRole('menu')
169
+ await user.keyboard('{ArrowDown}')
170
+ expect(highlighted()).toBe('Duplicate')
171
+ await user.keyboard('{ArrowDown}')
172
+ expect(highlighted()).toBe('Delete')
173
+ await user.keyboard('{ArrowDown}')
174
+ expect(highlighted()).toBe('Rename')
175
+ await user.keyboard('{ArrowUp}')
176
+ expect(highlighted()).toBe('Delete')
177
+ })
178
+
179
+ it('Home and End jump to the ends', async () => {
180
+ const user = userEvent.setup()
181
+ render(<Basic />)
182
+ screen.getByRole('button', { name: 'Trigger' }).focus()
183
+ await user.keyboard('{Enter}')
184
+ await screen.findByRole('menu')
185
+ await user.keyboard('{End}')
186
+ expect(highlighted()).toBe('Delete')
187
+ await user.keyboard('{Home}')
188
+ expect(highlighted()).toBe('Rename')
189
+ })
190
+
191
+ it('typing a row’s first letters jumps to it', async () => {
192
+ const user = userEvent.setup()
193
+ render(<Basic />)
194
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
195
+ await screen.findByRole('menu')
196
+ await user.keyboard('de')
197
+ expect(highlighted()).toBe('Delete')
198
+ })
199
+
200
+ it('Enter on the highlighted row activates it and closes the menu', async () => {
201
+ const user = userEvent.setup()
202
+ const onPick = vi.fn()
203
+ render(
204
+ <Menu trigger={<Button variant="outlined">Trigger</Button>}>
205
+ <MenuItem label="Rename" onClick={onPick} />
206
+ <MenuItem label="Delete" destructive onClick={() => {}} />
207
+ </Menu>,
208
+ )
209
+ screen.getByRole('button', { name: 'Trigger' }).focus()
210
+ await user.keyboard('{Enter}')
211
+ await screen.findByRole('menu')
212
+ await user.keyboard('{Enter}')
213
+ expect(onPick).toHaveBeenCalledTimes(1)
214
+ await waitFor(() => expect(screen.queryByRole('menu')).toBeNull())
215
+ })
216
+
217
+ /**
218
+ * → opens the submenu **and takes the highlight into it**; the parent row
219
+ * gives it up. The page said the highlight stayed on the row and ↓ stepped
220
+ * in, which was written from Base UI's documentation rather than from a
221
+ * measurement, and is wrong in a browser and here (2026-09-08).
222
+ */
223
+ it('→ opens a submenu onto its first row, and ← comes back', async () => {
224
+ const user = userEvent.setup()
225
+ render(
226
+ <Menu trigger={<Button variant="outlined">Trigger</Button>}>
227
+ <MenuItem label="Rename" onClick={() => {}} />
228
+ <MenuSub label="Move to…">
229
+ <MenuItem label="Item one" onClick={() => {}} />
230
+ <MenuItem label="Item two" onClick={() => {}} />
231
+ </MenuSub>
232
+ </Menu>,
233
+ )
234
+ screen.getByRole('button', { name: 'Trigger' }).focus()
235
+ await user.keyboard('{Enter}')
236
+ await screen.findByRole('menu')
237
+ await user.keyboard('{ArrowDown}')
238
+ expect(highlighted()).toBe('Move to…')
239
+ await user.keyboard('{ArrowRight}')
240
+ expect(await screen.findByRole('menuitem', { name: 'Item one' })).toBeTruthy()
241
+ expect(highlighted()).toBe('Item one')
242
+ await user.keyboard('{ArrowLeft}')
243
+ await waitFor(() => expect(screen.queryByRole('menuitem', { name: 'Item one' })).toBeNull())
244
+ expect(highlighted()).toBe('Move to…')
245
+ })
246
+
247
+ it('a submenu row says it opens one', async () => {
248
+ const user = userEvent.setup()
249
+ render(
250
+ <Menu trigger={<Button variant="outlined">Trigger</Button>}>
251
+ <MenuItem label="Rename" onClick={() => {}} />
252
+ <MenuSub label="Move to…">
253
+ <MenuItem label="Item one" onClick={() => {}} />
254
+ </MenuSub>
255
+ </Menu>,
256
+ )
257
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
258
+ await screen.findByRole('menu')
259
+ const sub = screen.getByRole('menuitem', { name: 'Move to…' })
260
+ expect(sub.getAttribute('aria-haspopup')).toBe('menu')
261
+ expect(sub.getAttribute('aria-expanded')).toBe('false')
262
+ expect(sub.tagName).toBe('BUTTON')
263
+ })
264
+ })
265
+
266
+ /**
267
+ * The rows outside a menu. Peek's `@`, `/` and `[` pickers and its compose
268
+ * menu draw the surface without the behaviour, because the editor's suggestion
269
+ * plugin already owns the keyboard — so `MenuItem` and `MenuSection` have to
270
+ * work with no `Menu` above them.
271
+ */
272
+ describe('rows on a bare MenuPanel', () => {
273
+ /**
274
+ * It is a button, and it does NOT claim to be a menu item. ARIA requires a
275
+ * `menuitem` to sit inside a `menu` or a `menubar`; `MenuPanel` is a `<div>`
276
+ * with no role, so the role used to make an orphan in all four of the Peek
277
+ * files that draw rows this way.
278
+ */
279
+ it('MenuItem is a plain button outside a menu, claiming no menu role', () => {
280
+ render(
281
+ <MenuPanel>
282
+ <MenuItem label="Heading" shortcut="#" onClick={() => {}} />
283
+ </MenuPanel>,
284
+ )
285
+ const item = screen.getByRole('button', { name: /Heading/ })
286
+ expect(item.tagName).toBe('BUTTON')
287
+ expect(item.getAttribute('role')).toBeNull()
288
+ expect(screen.queryByRole('menuitem')).toBeNull()
289
+ })
290
+
291
+ it('its onClick still runs', async () => {
292
+ const user = userEvent.setup()
293
+ const onPick = vi.fn()
294
+ render(
295
+ <MenuPanel>
296
+ <MenuItem label="Heading" onClick={onPick} />
297
+ </MenuPanel>,
298
+ )
299
+ await user.click(screen.getByRole('button', { name: 'Heading' }))
300
+ expect(onPick).toHaveBeenCalledTimes(1)
301
+ })
302
+
303
+ it('MenuSection draws its heading and its rows, and is not a group', () => {
304
+ render(
305
+ <MenuPanel>
306
+ <MenuSection label="Section">
307
+ <MenuItem label="Heading" onClick={() => {}} />
308
+ </MenuSection>
309
+ </MenuPanel>,
310
+ )
311
+ expect(screen.getByText('Section')).toBeTruthy()
312
+ expect(screen.getAllByRole('button')).toHaveLength(1)
313
+ expect(screen.queryByRole('group')).toBeNull()
314
+ })
315
+ })