@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,222 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconBold, IconItalic, IconLink } from '@tabler/icons-react'
3
+ import { useRef, useState, type KeyboardEvent } from 'react'
4
+ import { Button } from './Button'
5
+ import { MenuPanel } from './Menu'
6
+ import { Popover } from './Popover'
7
+ import { TextInput } from './TextInput'
8
+ import { Toolbar, ToolbarButton, ToolbarInput, ToolbarSeparator } from './Toolbar'
9
+
10
+ /**
11
+ * A floating panel from a trigger — the menu's surface, with none of a menu's
12
+ * semantics. Reach for it when the panel holds *content*: a field, a row of
13
+ * controls, a small form. Reach for **Menu** when it holds a list of actions.
14
+ *
15
+ * Like a menu it portals and places itself, so the canvas draws the surface
16
+ * with `MenuPanel` and everything below it is live (Katerina, D25).
17
+ */
18
+ const meta = {
19
+ title: 'Overlays/Popover',
20
+ component: Popover,
21
+ decorators: [(Story) => <div className="flex min-h-[260px] w-full items-start justify-center pt-6"><Story /></div>],
22
+ args: { children: null },
23
+ argTypes: {
24
+ trigger: { control: false },
25
+ children: { control: false },
26
+ anchor: { control: false },
27
+ onOpenChange: { control: false },
28
+ actionsRef: { control: false },
29
+ finalFocus: { control: false },
30
+ },
31
+ } satisfies Meta<typeof Popover>
32
+
33
+ export default meta
34
+ type Story = StoryObj<typeof meta>
35
+
36
+ /** The surface, at rest: whatever the caller puts in it. */
37
+ export const Default: Story = {
38
+ parameters: { controls: { disable: true } },
39
+ render: () => (
40
+ <MenuPanel className="w-[280px] gap-2">
41
+ <span className="text-body-2-strong text-text-primary">A small form</span>
42
+ <TextInput value="Item one" onChange={() => {}} aria-label="Name" />
43
+ <div className="flex justify-end gap-2">
44
+ <Button size="small">Cancel</Button>
45
+ <Button size="small" variant="primary">
46
+ Save
47
+ </Button>
48
+ </div>
49
+ </MenuPanel>
50
+ ),
51
+ }
52
+
53
+ /**
54
+ * Live. Click the button: focus lands in the field, Tab walks the contents in
55
+ * order because they are contents and not menu items, and Escape closes and
56
+ * gives focus back to the button.
57
+ *
58
+ * The Cancel and Save buttons are not menu items, so choosing one does not
59
+ * close the panel by itself — that is what `actionsRef` is for.
60
+ */
61
+ export const FromATrigger: Story = {
62
+ parameters: { controls: { disable: true } },
63
+ render: function Live() {
64
+ const [name, setName] = useState('Item one')
65
+ const actions = useRef<{ close: () => void; unmount: () => void } | null>(null)
66
+ return (
67
+ <Popover
68
+ trigger={<Button variant="outlined">Rename</Button>}
69
+ actionsRef={actions}
70
+ ariaLabel="Rename this"
71
+ className="w-[280px] gap-2"
72
+ >
73
+ <TextInput autoFocus value={name} onChange={(e) => setName(e.target.value)} aria-label="Name" />
74
+ <div className="flex justify-end gap-2">
75
+ <Button size="small" onClick={() => actions.current?.close()}>
76
+ Cancel
77
+ </Button>
78
+ <Button size="small" variant="primary" onClick={() => actions.current?.close()}>
79
+ Save
80
+ </Button>
81
+ </div>
82
+ </Popover>
83
+ )
84
+ },
85
+ }
86
+
87
+ /**
88
+ * The shape this component was added for: a `Toolbar` with a text field in
89
+ * it. In a `Menu` the arrow keys and the typeahead would fight the field.
90
+ *
91
+ * It opens **above** its trigger, as a panel holding a toolbar does — the
92
+ * strip acts on what is under it.
93
+ */
94
+ export const AToolbar: Story = {
95
+ parameters: { controls: { disable: true } },
96
+ render: function FormattingStrip() {
97
+ const [url, setUrl] = useState('')
98
+ return (
99
+ <Popover
100
+ trigger={<Button variant="outlined">Formatting</Button>}
101
+ ariaLabel="Formatting"
102
+ /* Above the trigger, like every panel that holds a toolbar: a strip of
103
+ controls acts on what is under it, so it stands over that rather
104
+ than on top of it. `side` is the *preference* — Base UI flips it
105
+ when there is no room, which is the reason the placement is its job
106
+ and not ours. */
107
+ side="top"
108
+ className="w-auto min-w-0 p-1"
109
+ >
110
+ {/* The strip is a `Toolbar`, so the whole row is ONE Tab stop and the
111
+ arrow keys walk it — four stops before, one after. */}
112
+ <Toolbar aria-label="Formatting" surface={false}>
113
+ <ToolbarButton aria-label="Bold" tooltip="Bold" tooltipShortcut="Cmd+B">
114
+ <IconBold size={16} stroke={1.5} />
115
+ </ToolbarButton>
116
+ <ToolbarButton aria-label="Italic" tooltip="Italic" tooltipShortcut="Cmd+I">
117
+ <IconItalic size={16} stroke={1.5} />
118
+ </ToolbarButton>
119
+ <ToolbarButton aria-label="Link" tooltip="Link">
120
+ <IconLink size={16} stroke={1.5} />
121
+ </ToolbarButton>
122
+ <ToolbarSeparator />
123
+ <ToolbarInput value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Paste a link" aria-label="Link address" className="h-7 w-48" />
124
+ </Toolbar>
125
+ </Popover>
126
+ )
127
+ },
128
+ }
129
+
130
+ /**
131
+ * The other mode: **no trigger element at all.** A panel hung from a rect the
132
+ * caller measured — a text selection, which is not a control and cannot be
133
+ * one. It is controlled, because there is nothing for Base UI to watch, and it
134
+ * takes `finalFocus` to say where focus goes when it closes.
135
+ *
136
+ * Select some of the text below.
137
+ */
138
+ export const FromASelection: Story = {
139
+ parameters: { controls: { disable: true } },
140
+ render: function Selection() {
141
+ const [rect, setRect] = useState<DOMRect | null>(null)
142
+ const body = useRef<HTMLParagraphElement>(null)
143
+ const read = () => {
144
+ const selection = window.getSelection()
145
+ if (!selection || selection.isCollapsed || !body.current?.contains(selection.anchorNode)) return setRect(null)
146
+ setRect(selection.getRangeAt(0).getBoundingClientRect())
147
+ }
148
+ /*
149
+ * Escape must not reopen what it just closed, and it did — measured
150
+ * 2026-09-08: the keydown closes the panel, `finalFocus` puts focus back
151
+ * on this paragraph, and the same key's KEYUP then lands here and re-reads
152
+ * a selection that is still perfectly alive. The panel reopened in the
153
+ * same gesture, so Escape looked as if it did nothing at all.
154
+ *
155
+ * A real editor's selection toolbar has exactly this shape, so the guard
156
+ * belongs in the example rather than in a footnote.
157
+ */
158
+ const readOnKey = (event: KeyboardEvent<HTMLParagraphElement>) => {
159
+ if (event.key === 'Escape') return
160
+ read()
161
+ }
162
+ return (
163
+ <div className="w-[420px]">
164
+ {/* `tabIndex={-1}`: `finalFocus` needs something that can take focus,
165
+ and a paragraph cannot until it is told it may. An editor already
166
+ can, which is the real case; `-1` gives this one the same property
167
+ without adding a Tab stop. Without it focus is left on the document
168
+ body when the panel closes — measured. */}
169
+ <p ref={body} tabIndex={-1} onMouseUp={read} onKeyUp={readOnKey} className="text-[14px] leading-[1.6] text-text-primary outline-none">
170
+ Select any part of this sentence with the pointer, and a panel appears
171
+ above the selection rather than beside a button — because a selection
172
+ is not a control and there is no trigger to hang from.
173
+ </p>
174
+ {/* Always rendered, `open` toggled — never `{rect && <Popover…>}`.
175
+ Mounting the panel only while it is open takes Base UI's own state
176
+ machine away with it, and the exits go with it: measured, Escape
177
+ closed the panel and a fresh selection sometimes failed to reopen
178
+ it, both intermittently. Rendered once and told when to open, both
179
+ are exact every time. */}
180
+ <Popover
181
+ anchor={rect}
182
+ open={rect !== null}
183
+ onOpenChange={(next) => !next && setRect(null)}
184
+ finalFocus={body}
185
+ /* Above the selection, not below it (Katerina, 2026-09-08): below,
186
+ the panel covers the line after the selection — and that is the
187
+ line that tells you what you have just selected. Base UI flips it
188
+ when the top has no room. */
189
+ side="top"
190
+ ariaLabel="Formatting"
191
+ className="w-auto min-w-0 p-1"
192
+ >
193
+ <Toolbar aria-label="Formatting" surface={false}>
194
+ <ToolbarButton aria-label="Bold" tooltip="Bold">
195
+ <IconBold size={16} stroke={1.5} />
196
+ </ToolbarButton>
197
+ <ToolbarButton aria-label="Italic" tooltip="Italic">
198
+ <IconItalic size={16} stroke={1.5} />
199
+ </ToolbarButton>
200
+ </Toolbar>
201
+ </Popover>
202
+ </div>
203
+ )
204
+ },
205
+ }
206
+
207
+ /**
208
+ * **The side is a preference, not a promise.** This one asks for the top in a
209
+ * row pinned to the top of the screen, so there is no room above it and Base
210
+ * UI puts it below. Deciding that is the whole reason the placement is not
211
+ * ours to compute.
212
+ */
213
+ export const FlippedForRoom: Story = {
214
+ parameters: { controls: { disable: true }, layout: 'fullscreen' },
215
+ render: () => (
216
+ <div className="flex h-[260px] w-full items-start justify-center pt-2">
217
+ <Popover trigger={<Button variant="outlined">Asks for the top</Button>} side="top" ariaLabel="A panel" className="w-[240px]">
218
+ <span className="text-body-2 text-text-primary">No room above, so it is below.</span>
219
+ </Popover>
220
+ </div>
221
+ ),
222
+ }
@@ -0,0 +1,188 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * What the Popover page claims, pinned — including the two things that
4
+ * distinguish it from a `Menu`, which is the whole reason it exists: its
5
+ * contents are not menu items, and a field inside it keeps its focus.
6
+ */
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
9
+ import userEvent from '@testing-library/user-event'
10
+ import { useRef, useState } from 'react'
11
+ import { Button } from './Button'
12
+ import { Popover } from './Popover'
13
+ import { TextInput } from './TextInput'
14
+
15
+ afterEach(cleanup)
16
+
17
+ function Basic({ children, onOpenChange }: { children?: React.ReactNode; onOpenChange?: (open: boolean) => void }) {
18
+ return (
19
+ <Popover trigger={<Button variant="outlined">Trigger</Button>} ariaLabel="A panel" onOpenChange={onOpenChange}>
20
+ {children ?? <button type="button">Inside</button>}
21
+ </Popover>
22
+ )
23
+ }
24
+
25
+ describe('Popover', () => {
26
+ it('is a named panel, portalled out of what rendered it', async () => {
27
+ const user = userEvent.setup()
28
+ const { container } = render(<Basic />)
29
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
30
+ const panel = await screen.findByRole('dialog', { name: 'A panel' })
31
+ expect(container.contains(panel)).toBe(false)
32
+ })
33
+
34
+ /** The distinction from Menu, in one assertion: nothing in here is a
35
+ * menuitem, so nothing takes the arrow keys or the typeahead. */
36
+ it('its contents are contents, not menu items', async () => {
37
+ const user = userEvent.setup()
38
+ render(<Basic />)
39
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
40
+ await screen.findByRole('dialog')
41
+ expect(screen.queryByRole('menu')).toBeNull()
42
+ expect(screen.queryAllByRole('menuitem')).toHaveLength(0)
43
+ expect(screen.getByRole('button', { name: 'Inside' })).toBeTruthy()
44
+ })
45
+
46
+ /** The link editor is why this component exists: a panel that steals focus
47
+ * from its own field would be useless. */
48
+ it('a field inside keeps the focus its autoFocus asked for', async () => {
49
+ const user = userEvent.setup()
50
+ function Field() {
51
+ const [value, setValue] = useState('')
52
+ return <TextInput autoFocus value={value} onChange={(e) => setValue(e.target.value)} aria-label="Link address" />
53
+ }
54
+ render(
55
+ <Basic>
56
+ <Field />
57
+ </Basic>,
58
+ )
59
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
60
+ const field = await screen.findByRole('textbox', { name: 'Link address' })
61
+ expect(document.activeElement).toBe(field)
62
+ await user.keyboard('abc')
63
+ expect((field as HTMLInputElement).value).toBe('abc')
64
+ })
65
+
66
+ it('closes on Escape and gives focus back to the trigger', async () => {
67
+ const user = userEvent.setup()
68
+ render(<Basic />)
69
+ const trigger = screen.getByRole('button', { name: 'Trigger' })
70
+ await user.click(trigger)
71
+ await screen.findByRole('dialog')
72
+ await user.keyboard('{Escape}')
73
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
74
+ expect(document.activeElement).toBe(trigger)
75
+ })
76
+
77
+ it('closes on a press outside', async () => {
78
+ const user = userEvent.setup()
79
+ render(
80
+ <>
81
+ <Basic />
82
+ <button type="button">Elsewhere</button>
83
+ </>,
84
+ )
85
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
86
+ await screen.findByRole('dialog')
87
+ await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
88
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
89
+ })
90
+
91
+ it('a second press of the trigger closes it', async () => {
92
+ const user = userEvent.setup()
93
+ render(<Basic />)
94
+ const trigger = screen.getByRole('button', { name: 'Trigger' })
95
+ await user.click(trigger)
96
+ await screen.findByRole('dialog')
97
+ await user.click(trigger)
98
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
99
+ })
100
+
101
+ /** A control inside a panel is content, not a menu row: pressing it does not
102
+ * close the panel, which is what `actionsRef` is for. */
103
+ it('a button inside does not close it; actionsRef does', async () => {
104
+ const user = userEvent.setup()
105
+ function WithActions() {
106
+ const actions = useRef<{ close: () => void; unmount: () => void } | null>(null)
107
+ return (
108
+ <Popover trigger={<Button variant="outlined">Trigger</Button>} actionsRef={actions} ariaLabel="A panel">
109
+ <Button size="small">Does nothing</Button>
110
+ <Button size="small" onClick={() => actions.current?.close()}>
111
+ Close
112
+ </Button>
113
+ </Popover>
114
+ )
115
+ }
116
+ render(<WithActions />)
117
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
118
+ await screen.findByRole('dialog')
119
+ await user.click(screen.getByRole('button', { name: 'Does nothing' }))
120
+ expect(screen.queryByRole('dialog')).toBeTruthy()
121
+ await user.click(screen.getByRole('button', { name: 'Close' }))
122
+ await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
123
+ })
124
+
125
+ it('reports opening and closing to a caller that asks', async () => {
126
+ const user = userEvent.setup()
127
+ const onOpenChange = vi.fn()
128
+ render(<Basic onOpenChange={onOpenChange} />)
129
+ await user.click(screen.getByRole('button', { name: 'Trigger' }))
130
+ await screen.findByRole('dialog')
131
+ expect(onOpenChange).toHaveBeenCalledWith(true)
132
+ await user.keyboard('{Escape}')
133
+ expect(onOpenChange).toHaveBeenCalledWith(false)
134
+ })
135
+
136
+ /** The other mode: no trigger element, hung from a rect the caller measured. */
137
+ it('opens from an anchored rect with no trigger at all', async () => {
138
+ render(
139
+ <Popover anchor={new DOMRect(10, 10, 40, 20)} open ariaLabel="Formatting">
140
+ <button type="button">Bold</button>
141
+ </Popover>,
142
+ )
143
+ expect(await screen.findByRole('dialog', { name: 'Formatting' })).toBeTruthy()
144
+ expect(screen.queryByRole('button', { name: 'Trigger' })).toBeNull()
145
+ expect(screen.getByRole('button', { name: 'Bold' })).toBeTruthy()
146
+ })
147
+
148
+ /**
149
+ * An anchored panel leaves focus where it was. The person is still in
150
+ * whatever produced it — a text selection means a caret in the text — and a
151
+ * panel that took the caret would end the edit it exists to serve.
152
+ */
153
+ it('an anchored panel does not take focus', async () => {
154
+ render(
155
+ <>
156
+ <input aria-label="Where the person is" />
157
+ <Popover anchor={new DOMRect(10, 10, 40, 20)} open ariaLabel="Formatting">
158
+ <button type="button">Bold</button>
159
+ </Popover>
160
+ </>,
161
+ )
162
+ const field = screen.getByRole('textbox', { name: 'Where the person is' })
163
+ field.focus()
164
+ await screen.findByRole('dialog')
165
+ expect(document.activeElement).toBe(field)
166
+ })
167
+
168
+ /** An anchored panel has the same two exits as any other. It is the mode
169
+ * with no trigger, so this is the only place they can be asserted. */
170
+ it('an anchored panel still closes on Escape and on a press outside', async () => {
171
+ const user = userEvent.setup()
172
+ const onOpenChange = vi.fn()
173
+ render(
174
+ <>
175
+ <Popover anchor={new DOMRect(10, 10, 40, 20)} open onOpenChange={onOpenChange} ariaLabel="Formatting">
176
+ <button type="button">Bold</button>
177
+ </Popover>
178
+ <button type="button">Elsewhere</button>
179
+ </>,
180
+ )
181
+ await screen.findByRole('dialog')
182
+ await user.keyboard('{Escape}')
183
+ expect(onOpenChange).toHaveBeenCalledWith(false)
184
+ onOpenChange.mockClear()
185
+ await user.click(screen.getByRole('button', { name: 'Elsewhere' }))
186
+ expect(onOpenChange).toHaveBeenCalledWith(false)
187
+ })
188
+ })
@@ -0,0 +1,156 @@
1
+ import { useMemo, type ReactElement, type ReactNode, type RefObject } from 'react'
2
+ import { Popover as BasePopover } from '@base-ui/react/popover'
3
+ import { cn } from './cn'
4
+ import { triggerDisabled } from './triggerDisabled'
5
+ import { MenuPanel } from './Menu'
6
+
7
+ /**
8
+ * A floating panel from a trigger — the same elevated surface a `Menu` uses,
9
+ * with none of a menu's semantics. New at stage 4 (2026-09-07).
10
+ *
11
+ * **It exists because `Menu` was being used for this.** Peek's selection
12
+ * toolbar puts a text field inside one, and its debug panel fills one with
13
+ * toggle rows; neither is a list of actions, and since the menus moved onto
14
+ * Base UI a `Menu` gives its contents roving focus and typeahead, which is
15
+ * wrong for both and fights a text field outright. Beyond those two, Peek
16
+ * hand-writes overlay behaviour in thirteen files — six `createPortal`, five
17
+ * outside-click listeners, seven position calculations against
18
+ * `window.innerWidth` (`COMPONENTS-PEEK.md` F5). This is what they become.
19
+ *
20
+ * **The API is `Menu`'s**: it takes the `trigger` and owns everything after —
21
+ * the toggle, the placement, the dismissal and the focus return. What differs
22
+ * is inside: a `Popover` announces itself as a dialog, its contents are
23
+ * ordinary content, and Tab walks them in order.
24
+ *
25
+ * The one thing a `Menu` has no use for is the second mode below: a panel with
26
+ * no trigger element at all, hung from a rect the caller measured — a toolbar
27
+ * over a text selection. That mode is controlled, because there is nothing for
28
+ * Base UI to watch.
29
+ */
30
+ export interface PopoverProps {
31
+ /**
32
+ * The control that opens the panel. Any element that forwards its ref and
33
+ * spreads its props — this package's `Button`, `IconButton` and
34
+ * `PersonTrigger` all do.
35
+ *
36
+ * Base UI can only do the toggle, the placement, the dismissal and the focus
37
+ * return if it knows which element opened the panel. Give it the trigger
38
+ * unless there is genuinely no element to give — see `anchor`.
39
+ */
40
+ trigger?: ReactElement
41
+ /**
42
+ * For a panel with **no trigger element**: an element, or a rect the caller
43
+ * measured — a text selection's. Pair it with `open`, since there is nothing
44
+ * for Base UI to watch.
45
+ *
46
+ * An anchored panel **does not take focus**, because the person is still in
47
+ * whatever produced it. That also means it cannot be reached by keyboard, so
48
+ * everything in one must be reachable another way.
49
+ *
50
+ * Render it always and toggle `open`; do not mount it only while it is open.
51
+ */
52
+ anchor?: HTMLElement | DOMRect | null
53
+ /** Which of the panel's edges hangs from the trigger's. Default left. */
54
+ align?: 'left' | 'right'
55
+ /**
56
+ * Which side of the trigger, or of the anchor, the panel prefers. Default
57
+ * `bottom`.
58
+ *
59
+ * **A toolbar over a text selection wants `top`** (Katerina, 2026-09-08):
60
+ * below, it covers the line you are about to read next, and it is the line
61
+ * *after* the selection that tells you what you have selected. It is a
62
+ * preference, not a promise — Base UI flips it when that side has no room.
63
+ */
64
+ side?: 'top' | 'bottom'
65
+ /** Controlled, for a caller that must know or must force it. Required with
66
+ * `anchor`; with a `trigger`, leave both off and the panel keeps its own. */
67
+ open?: boolean
68
+ onOpenChange?: (open: boolean) => void
69
+ /**
70
+ * Where focus goes when the panel closes. With a `trigger` it goes back to
71
+ * the trigger and this is not needed. An anchored panel never took focus, so
72
+ * this only matters when something inside it did — a field the person tabbed
73
+ * or clicked into: point it at what they came from, or focus is left on the
74
+ * document body.
75
+ */
76
+ finalFocus?: RefObject<HTMLElement | null>
77
+ /** Base UI's imperative handle. `actions.current?.close()` shuts the panel —
78
+ * for the Cancel and Save buttons a form panel ends with. */
79
+ actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
80
+ /** Names the panel for assistive tech. A panel with a visible heading can
81
+ * point at it instead, with `aria-labelledby`. */
82
+ ariaLabel?: string
83
+ children: ReactNode
84
+ /** On the panel's surface — its width, its internal rhythm. */
85
+ className?: string
86
+ }
87
+
88
+ /** The 4px between the panel and what it hangs from, and the 8px it keeps
89
+ * clear of every screen edge — `Menu`'s numbers, because it is the same box. */
90
+ const GAP = 4
91
+ const VIEWPORT_PAD = 8
92
+
93
+ export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open, onOpenChange, finalFocus, actionsRef, ariaLabel, children, className }: PopoverProps) {
94
+ /* A rect is not an element, so it becomes a virtual anchor — the one shape
95
+ Floating UI takes besides an element. */
96
+ const anchorTarget = useMemo(() => {
97
+ if (!anchor) return undefined
98
+ if (anchor instanceof Element) return anchor
99
+ return { getBoundingClientRect: () => anchor }
100
+ }, [anchor])
101
+
102
+ return (
103
+ <BasePopover.Root
104
+ open={open}
105
+ onOpenChange={onOpenChange ? (next) => onOpenChange(next) : undefined}
106
+ actionsRef={actionsRef}
107
+ /* Non-modal: the page behind keeps its scrollbar, so opening a panel
108
+ never shifts the layout, and a toolbar over a text selection must not
109
+ take the page away from the person using it. */
110
+ modal={false}
111
+ >
112
+ {/* Told the button's disabled state, or it writes `aria-disabled="false"`
113
+ over it and opens anyway (Finding 39). */}
114
+ {trigger && <BasePopover.Trigger render={trigger} disabled={triggerDisabled(trigger)} />}
115
+ <BasePopover.Portal>
116
+ <BasePopover.Positioner
117
+ anchor={anchorTarget}
118
+ side={side}
119
+ align={align === 'right' ? 'end' : 'start'}
120
+ sideOffset={GAP}
121
+ collisionPadding={VIEWPORT_PAD}
122
+ className="z-50 data-[anchor-hidden]:hidden"
123
+ >
124
+ <BasePopover.Popup
125
+ aria-label={ariaLabel}
126
+ /*
127
+ * From a trigger, focus lands on the first thing in the panel —
128
+ * the field, in the panel this component exists for — and goes
129
+ * back to the trigger when it closes.
130
+ *
131
+ * From an anchor, it does not move at all. A panel with no trigger
132
+ * appeared rather than being asked for, and the person is still in
133
+ * the middle of what produced it: a toolbar over a text selection
134
+ * that took the caret out of the text would end the edit it exists
135
+ * to serve. Measured 2026-09-08: with focus moved into the panel,
136
+ * the first control's tooltip opens on `:focus-visible` and eats
137
+ * the Escape that should have closed the panel, and the caller's
138
+ * re-read of the selection fights the panel's own dismissal — both
139
+ * intermittently. Neither happens once focus stays put.
140
+ *
141
+ * The cost is stated on the page: an anchored panel cannot be
142
+ * reached by keyboard, so what is in one must also be reachable
143
+ * some other way.
144
+ */
145
+ initialFocus={trigger ? undefined : false}
146
+ finalFocus={finalFocus}
147
+ className={cn('min-w-[180px] max-h-[var(--available-height)] overflow-y-auto outline-none', className)}
148
+ render={<MenuPanel />}
149
+ >
150
+ {children}
151
+ </BasePopover.Popup>
152
+ </BasePopover.Positioner>
153
+ </BasePopover.Portal>
154
+ </BasePopover.Root>
155
+ )
156
+ }
@@ -0,0 +1,98 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as PreviewCardStories from './PreviewCard.stories'
3
+
4
+ <Meta of={PreviewCardStories} />
5
+
6
+ # PreviewCard
7
+
8
+ **More of a thing, on hover.**
9
+
10
+ A list shows one line per thing, because a list has to. This is where the rest
11
+ of that thing goes: rest the pointer on a row and a card opens beside it,
12
+ holding whatever the row had no room for. It closes when the pointer leaves,
13
+ and — unlike a tooltip — you can move into it, so what it holds can be long,
14
+ can scroll, and can be read at your own pace.
15
+
16
+ It is a **read**, not a control. Nothing in a preview card may be the only way
17
+ to reach anything: there is no keyboard trigger, so a person who does not use a
18
+ pointer never sees it.
19
+
20
+ <Canvas of={PreviewCardStories.Default} />
21
+
22
+ ## When
23
+
24
+ - **A row is a summary and the thing has more to it.** The row stays one line;
25
+ the card carries the rest. This is the case it exists for.
26
+ - **A reference could say what it refers to** — a name, a code, a short id —
27
+ without a trip to another page.
28
+ - **The reader would otherwise click, look, and come back.** If a preview
29
+ saves that round trip, it earns its place.
30
+
31
+ ## When not
32
+
33
+ - **A word for a control** → **WithTooltip**. The test is not length, it is
34
+ whether the content is worth pointing at: a label is a tooltip, something you
35
+ might read, scroll or copy is this.
36
+ - **Anything the reader must have** → put it in the page. A hover is not a
37
+ route; it does not exist for a keyboard.
38
+ - **Content with controls in it** → **Popover**, opened by a click, so the
39
+ controls can be reached and stayed with.
40
+ - **A list of actions** → **Menu**.
41
+
42
+ ## How
43
+
44
+ ```tsx
45
+ import { PreviewCard } from '@estiva-app/ui'
46
+
47
+ <PreviewCard content={<ItemDetail id={row.id} />} wrapperClassName="block w-full">
48
+ <ListRow …/>
49
+ </PreviewCard>
50
+ ```
51
+
52
+ - **`content` renders only while the card is open.** A preview that fetches
53
+ therefore fetches for the row being pointed at, not once per row on screen.
54
+ - **It opens 350ms after the pointer comes to rest** and closes 200ms after it
55
+ leaves. The wait stops a card flashing at every row as a pointer crosses a
56
+ list; the 200ms is what lets you cross the gap into the card.
57
+ - **It prefers the right of the trigger and flips** when that side has no room,
58
+ staying 12px from the trigger and 8px clear of every screen edge. `side`
59
+ changes the preference, not the promise.
60
+ - **Width, padding and height are yours** through `className`. The default is
61
+ 360px wide, capped at 300px or the room available, whichever is smaller — a
62
+ preview of one kind of thing is not the size of a preview of another.
63
+ - The trigger wrapper is `inline-flex`; a full-width row wants
64
+ `wrapperClassName="block w-full"`.
65
+
66
+ <Canvas of={PreviewCardStories.FlippedAtAnEdge} />
67
+
68
+ ### It can scroll, and that is the point
69
+
70
+ A card taller than its cap scrolls, and the reader can reach the scrollbar
71
+ because the pointer can enter the card. That is the difference from a tooltip,
72
+ stated as a behaviour rather than as a rule.
73
+
74
+ <Canvas of={PreviewCardStories.Scrolling} />
75
+
76
+ ### While it is loading
77
+
78
+ Draw a **skeleton**, not a spinner. A preview has a known shape, so the
79
+ skeleton says "this is what is coming"; a spinner only says "wait". It also
80
+ holds the card at the size it is about to be, so nothing jumps when the content
81
+ lands.
82
+
83
+ <Canvas of={PreviewCardStories.Loading} />
84
+
85
+ ## Keys
86
+
87
+ There is no keyboard trigger, by design — see above.
88
+
89
+ | Input | What happens |
90
+ |---|---|
91
+ | pointer rests on the trigger | opens after 350ms |
92
+ | pointer moves into the card | it stays up, and can be scrolled |
93
+ | pointer leaves both | closes after 200ms |
94
+ | Escape | closes |
95
+
96
+ ## Props
97
+
98
+ <Controls of={PreviewCardStories.OnARow} />