@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.
- package/dist/Avatar.d.ts +13 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/Banner.d.ts +16 -1
- package/dist/Banner.d.ts.map +1 -1
- package/dist/Breadcrumb.d.ts +9 -0
- package/dist/Breadcrumb.d.ts.map +1 -1
- package/dist/Button.d.ts +12 -2
- package/dist/Button.d.ts.map +1 -1
- package/dist/Checkbox.d.ts +2 -0
- package/dist/Checkbox.d.ts.map +1 -1
- package/dist/Chip.d.ts +3 -2
- package/dist/Chip.d.ts.map +1 -1
- package/dist/ChipInput.d.ts +3 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/ConfirmDialog.d.ts +5 -0
- package/dist/ConfirmDialog.d.ts.map +1 -1
- package/dist/DialogShell.d.ts +27 -3
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +6 -0
- package/dist/EditableText.d.ts.map +1 -1
- package/dist/Field.d.ts +42 -25
- package/dist/Field.d.ts.map +1 -1
- package/dist/IconButton.d.ts +3 -2
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +25 -13
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Menu.d.ts +65 -82
- package/dist/Menu.d.ts.map +1 -1
- package/dist/PersonTrigger.d.ts.map +1 -1
- package/dist/Popover.d.ts +86 -0
- package/dist/Popover.d.ts.map +1 -0
- package/dist/PreviewCard.d.ts +39 -0
- package/dist/PreviewCard.d.ts.map +1 -0
- package/dist/Reaction.d.ts.map +1 -1
- package/dist/ReactionPicker.d.ts +55 -0
- package/dist/ReactionPicker.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +5 -0
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Select.d.ts +26 -16
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tabs.d.ts +11 -1
- package/dist/Tabs.d.ts.map +1 -1
- package/dist/TextInput.d.ts +7 -1
- package/dist/TextInput.d.ts.map +1 -1
- package/dist/Textarea.d.ts +8 -1
- package/dist/Textarea.d.ts.map +1 -1
- package/dist/Toolbar.d.ts +95 -0
- package/dist/Toolbar.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +36 -10
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/cn.d.ts.map +1 -1
- package/dist/fit.d.ts +7 -72
- package/dist/fit.d.ts.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +882 -763
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/Avatar.mdx +17 -0
- package/src/Avatar.name.test.tsx +99 -0
- package/src/Avatar.tsx +25 -3
- package/src/AvatarGroup.tsx +4 -1
- package/src/Banner.mdx +10 -2
- package/src/Banner.stories.tsx +29 -0
- package/src/Banner.test.tsx +60 -0
- package/src/Banner.tsx +35 -3
- package/src/Breadcrumb.mdx +5 -2
- package/src/Breadcrumb.test.tsx +44 -0
- package/src/Breadcrumb.tsx +10 -2
- package/src/Button.compose.test.tsx +119 -0
- package/src/Button.mdx +7 -5
- package/src/Button.test.tsx +2 -1
- package/src/Button.tsx +21 -5
- package/src/Checkbox.tsx +3 -0
- package/src/Chip.tsx +3 -2
- package/src/ChipInput.tsx +4 -0
- package/src/ConfirmDialog.mdx +20 -1
- package/src/ConfirmDialog.test.tsx +109 -0
- package/src/ConfirmDialog.tsx +6 -0
- package/src/DialogShell.mdx +18 -4
- package/src/DialogShell.stories.tsx +56 -6
- package/src/DialogShell.test.tsx +184 -0
- package/src/DialogShell.tsx +108 -39
- package/src/EditableText.mdx +6 -1
- package/src/EditableText.test.tsx +156 -0
- package/src/EditableText.tsx +23 -5
- package/src/Field.mdx +23 -3
- package/src/Field.stories.tsx +35 -0
- package/src/Field.test.tsx +117 -27
- package/src/Field.tsx +86 -58
- package/src/IconButton.mdx +6 -2
- package/src/IconButton.test.tsx +5 -2
- package/src/IconButton.tsx +12 -6
- package/src/IdentityMenu.mdx +29 -6
- package/src/IdentityMenu.stories.tsx +41 -6
- package/src/IdentityMenu.tsx +74 -47
- package/src/Kbd.stories.tsx +3 -3
- package/src/Menu.mdx +88 -75
- package/src/Menu.stories.tsx +120 -52
- package/src/Menu.test.tsx +315 -0
- package/src/Menu.tsx +341 -251
- package/src/MenuItem.stories.tsx +135 -98
- package/src/NavItem.mdx +1 -1
- package/src/PersonTrigger.mdx +20 -14
- package/src/PersonTrigger.tsx +23 -1
- package/src/Popover.mdx +130 -0
- package/src/Popover.stories.tsx +222 -0
- package/src/Popover.test.tsx +188 -0
- package/src/Popover.tsx +153 -0
- package/src/PreviewCard.mdx +98 -0
- package/src/PreviewCard.stories.tsx +168 -0
- package/src/PreviewCard.test.tsx +83 -0
- package/src/PreviewCard.tsx +91 -0
- package/src/Rail.mdx +63 -0
- package/src/Rail.stories.tsx +81 -0
- package/src/Reaction.mdx +4 -2
- package/src/Reaction.tsx +4 -2
- package/src/ReactionPicker.mdx +85 -0
- package/src/ReactionPicker.stories.tsx +120 -0
- package/src/ReactionPicker.test.tsx +118 -0
- package/src/ReactionPicker.tsx +88 -0
- package/src/SearchInput.mdx +6 -2
- package/src/SearchInput.tsx +7 -1
- package/src/Select.mdx +16 -5
- package/src/Select.test.tsx +157 -0
- package/src/Select.tsx +89 -214
- package/src/Sidebar.mdx +6 -2
- package/src/Tabs.mdx +5 -0
- package/src/Tabs.test.tsx +15 -0
- package/src/Tabs.tsx +17 -2
- package/src/TextInput.mdx +8 -2
- package/src/TextInput.tsx +10 -7
- package/src/Textarea.mdx +6 -2
- package/src/Textarea.tsx +14 -9
- package/src/Toolbar.mdx +104 -0
- package/src/Toolbar.stories.tsx +142 -0
- package/src/Toolbar.test.tsx +198 -0
- package/src/Toolbar.tsx +158 -0
- package/src/Tooltip.mdx +43 -5
- package/src/Tooltip.stories.tsx +26 -0
- package/src/Tooltip.test.tsx +195 -0
- package/src/Tooltip.tsx +150 -48
- package/src/cn.ts +1 -1
- package/src/fit.test.ts +101 -0
- package/src/fit.ts +19 -63
- package/src/index.ts +6 -3
- package/stories/Choosing.mdx +8 -3
- package/tailwind-preset.js +7 -0
- package/src/Menu.fit.test.ts +0 -90
- 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
|
+
})
|
package/src/Popover.tsx
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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 { MenuPanel } from './Menu'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A floating panel from a trigger — the same elevated surface a `Menu` uses,
|
|
8
|
+
* with none of a menu's semantics. New at stage 4 (2026-09-07).
|
|
9
|
+
*
|
|
10
|
+
* **It exists because `Menu` was being used for this.** Peek's selection
|
|
11
|
+
* toolbar puts a text field inside one, and its debug panel fills one with
|
|
12
|
+
* toggle rows; neither is a list of actions, and since the menus moved onto
|
|
13
|
+
* Base UI a `Menu` gives its contents roving focus and typeahead, which is
|
|
14
|
+
* wrong for both and fights a text field outright. Beyond those two, Peek
|
|
15
|
+
* hand-writes overlay behaviour in thirteen files — six `createPortal`, five
|
|
16
|
+
* outside-click listeners, seven position calculations against
|
|
17
|
+
* `window.innerWidth` (`COMPONENTS-PEEK.md` F5). This is what they become.
|
|
18
|
+
*
|
|
19
|
+
* **The API is `Menu`'s**: it takes the `trigger` and owns everything after —
|
|
20
|
+
* the toggle, the placement, the dismissal and the focus return. What differs
|
|
21
|
+
* is inside: a `Popover` announces itself as a dialog, its contents are
|
|
22
|
+
* ordinary content, and Tab walks them in order.
|
|
23
|
+
*
|
|
24
|
+
* The one thing a `Menu` has no use for is the second mode below: a panel with
|
|
25
|
+
* no trigger element at all, hung from a rect the caller measured — a toolbar
|
|
26
|
+
* over a text selection. That mode is controlled, because there is nothing for
|
|
27
|
+
* Base UI to watch.
|
|
28
|
+
*/
|
|
29
|
+
export interface PopoverProps {
|
|
30
|
+
/**
|
|
31
|
+
* The control that opens the panel. Any element that forwards its ref and
|
|
32
|
+
* spreads its props — this package's `Button`, `IconButton` and
|
|
33
|
+
* `PersonTrigger` all do.
|
|
34
|
+
*
|
|
35
|
+
* Base UI can only do the toggle, the placement, the dismissal and the focus
|
|
36
|
+
* return if it knows which element opened the panel. Give it the trigger
|
|
37
|
+
* unless there is genuinely no element to give — see `anchor`.
|
|
38
|
+
*/
|
|
39
|
+
trigger?: ReactElement
|
|
40
|
+
/**
|
|
41
|
+
* For a panel with **no trigger element**: an element, or a rect the caller
|
|
42
|
+
* measured — a text selection's. Pair it with `open`, since there is nothing
|
|
43
|
+
* for Base UI to watch.
|
|
44
|
+
*
|
|
45
|
+
* An anchored panel **does not take focus**, because the person is still in
|
|
46
|
+
* whatever produced it. That also means it cannot be reached by keyboard, so
|
|
47
|
+
* everything in one must be reachable another way.
|
|
48
|
+
*
|
|
49
|
+
* Render it always and toggle `open`; do not mount it only while it is open.
|
|
50
|
+
*/
|
|
51
|
+
anchor?: HTMLElement | DOMRect | null
|
|
52
|
+
/** Which of the panel's edges hangs from the trigger's. Default left. */
|
|
53
|
+
align?: 'left' | 'right'
|
|
54
|
+
/**
|
|
55
|
+
* Which side of the trigger, or of the anchor, the panel prefers. Default
|
|
56
|
+
* `bottom`.
|
|
57
|
+
*
|
|
58
|
+
* **A toolbar over a text selection wants `top`** (Katerina, 2026-09-08):
|
|
59
|
+
* below, it covers the line you are about to read next, and it is the line
|
|
60
|
+
* *after* the selection that tells you what you have selected. It is a
|
|
61
|
+
* preference, not a promise — Base UI flips it when that side has no room.
|
|
62
|
+
*/
|
|
63
|
+
side?: 'top' | 'bottom'
|
|
64
|
+
/** Controlled, for a caller that must know or must force it. Required with
|
|
65
|
+
* `anchor`; with a `trigger`, leave both off and the panel keeps its own. */
|
|
66
|
+
open?: boolean
|
|
67
|
+
onOpenChange?: (open: boolean) => void
|
|
68
|
+
/**
|
|
69
|
+
* Where focus goes when the panel closes. With a `trigger` it goes back to
|
|
70
|
+
* the trigger and this is not needed. An anchored panel never took focus, so
|
|
71
|
+
* this only matters when something inside it did — a field the person tabbed
|
|
72
|
+
* or clicked into: point it at what they came from, or focus is left on the
|
|
73
|
+
* document body.
|
|
74
|
+
*/
|
|
75
|
+
finalFocus?: RefObject<HTMLElement | null>
|
|
76
|
+
/** Base UI's imperative handle. `actions.current?.close()` shuts the panel —
|
|
77
|
+
* for the Cancel and Save buttons a form panel ends with. */
|
|
78
|
+
actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
|
|
79
|
+
/** Names the panel for assistive tech. A panel with a visible heading can
|
|
80
|
+
* point at it instead, with `aria-labelledby`. */
|
|
81
|
+
ariaLabel?: string
|
|
82
|
+
children: ReactNode
|
|
83
|
+
/** On the panel's surface — its width, its internal rhythm. */
|
|
84
|
+
className?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The 4px between the panel and what it hangs from, and the 8px it keeps
|
|
88
|
+
* clear of every screen edge — `Menu`'s numbers, because it is the same box. */
|
|
89
|
+
const GAP = 4
|
|
90
|
+
const VIEWPORT_PAD = 8
|
|
91
|
+
|
|
92
|
+
export function Popover({ trigger, anchor, align = 'left', side = 'bottom', open, onOpenChange, finalFocus, actionsRef, ariaLabel, children, className }: PopoverProps) {
|
|
93
|
+
/* A rect is not an element, so it becomes a virtual anchor — the one shape
|
|
94
|
+
Floating UI takes besides an element. */
|
|
95
|
+
const anchorTarget = useMemo(() => {
|
|
96
|
+
if (!anchor) return undefined
|
|
97
|
+
if (anchor instanceof Element) return anchor
|
|
98
|
+
return { getBoundingClientRect: () => anchor }
|
|
99
|
+
}, [anchor])
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<BasePopover.Root
|
|
103
|
+
open={open}
|
|
104
|
+
onOpenChange={onOpenChange ? (next) => onOpenChange(next) : undefined}
|
|
105
|
+
actionsRef={actionsRef}
|
|
106
|
+
/* Non-modal: the page behind keeps its scrollbar, so opening a panel
|
|
107
|
+
never shifts the layout, and a toolbar over a text selection must not
|
|
108
|
+
take the page away from the person using it. */
|
|
109
|
+
modal={false}
|
|
110
|
+
>
|
|
111
|
+
{trigger && <BasePopover.Trigger render={trigger} />}
|
|
112
|
+
<BasePopover.Portal>
|
|
113
|
+
<BasePopover.Positioner
|
|
114
|
+
anchor={anchorTarget}
|
|
115
|
+
side={side}
|
|
116
|
+
align={align === 'right' ? 'end' : 'start'}
|
|
117
|
+
sideOffset={GAP}
|
|
118
|
+
collisionPadding={VIEWPORT_PAD}
|
|
119
|
+
className="z-50 data-[anchor-hidden]:hidden"
|
|
120
|
+
>
|
|
121
|
+
<BasePopover.Popup
|
|
122
|
+
aria-label={ariaLabel}
|
|
123
|
+
/*
|
|
124
|
+
* From a trigger, focus lands on the first thing in the panel —
|
|
125
|
+
* the field, in the panel this component exists for — and goes
|
|
126
|
+
* back to the trigger when it closes.
|
|
127
|
+
*
|
|
128
|
+
* From an anchor, it does not move at all. A panel with no trigger
|
|
129
|
+
* appeared rather than being asked for, and the person is still in
|
|
130
|
+
* the middle of what produced it: a toolbar over a text selection
|
|
131
|
+
* that took the caret out of the text would end the edit it exists
|
|
132
|
+
* to serve. Measured 2026-09-08: with focus moved into the panel,
|
|
133
|
+
* the first control's tooltip opens on `:focus-visible` and eats
|
|
134
|
+
* the Escape that should have closed the panel, and the caller's
|
|
135
|
+
* re-read of the selection fights the panel's own dismissal — both
|
|
136
|
+
* intermittently. Neither happens once focus stays put.
|
|
137
|
+
*
|
|
138
|
+
* The cost is stated on the page: an anchored panel cannot be
|
|
139
|
+
* reached by keyboard, so what is in one must also be reachable
|
|
140
|
+
* some other way.
|
|
141
|
+
*/
|
|
142
|
+
initialFocus={trigger ? undefined : false}
|
|
143
|
+
finalFocus={finalFocus}
|
|
144
|
+
className={cn('min-w-[180px] max-h-[var(--available-height)] overflow-y-auto outline-none', className)}
|
|
145
|
+
render={<MenuPanel />}
|
|
146
|
+
>
|
|
147
|
+
{children}
|
|
148
|
+
</BasePopover.Popup>
|
|
149
|
+
</BasePopover.Positioner>
|
|
150
|
+
</BasePopover.Portal>
|
|
151
|
+
</BasePopover.Root>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
@@ -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} />
|