@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
package/src/IdentityMenu.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useCallback, useRef, type ReactNode } from 'react'
|
|
2
2
|
import { cn } from './cn'
|
|
3
3
|
import { Divider } from './Divider'
|
|
4
|
-
import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
|
|
4
|
+
import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection } from './Menu'
|
|
5
5
|
import { Person } from './Person'
|
|
6
6
|
import { PersonTrigger } from './PersonTrigger'
|
|
7
7
|
|
|
@@ -53,28 +53,46 @@ export interface IdentityMenuProps {
|
|
|
53
53
|
children?: ReactNode | ((close: () => void) => ReactNode)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
56
|
+
/**
|
|
57
|
+
* The menu alone, as a plain surface — what the docs canvases draw, and what
|
|
58
|
+
* a surface that wants the rows without the menu can use.
|
|
59
|
+
*/
|
|
60
|
+
export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact'> {
|
|
61
|
+
/** Called when a row asks the menu to close. */
|
|
62
|
+
onClose?: () => void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function IdentityPanel({ className, onClose = () => {}, ...rest }: IdentityPanelProps) {
|
|
66
|
+
return (
|
|
67
|
+
<MenuPanel className={cn('w-72', className)}>
|
|
68
|
+
<IdentityRows {...rest} onClose={onClose} />
|
|
69
|
+
</MenuPanel>
|
|
70
|
+
)
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
/** The
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
/** The old name for the same thing. */
|
|
74
|
+
export const IdentityPanelSurface = IdentityPanel
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The panel's contents, without the menu around them.
|
|
78
|
+
*
|
|
79
|
+
* Split out at stage 4 for the same reason `MenuPanel` was split out of
|
|
80
|
+
* `Menu`: a real menu portals and places itself against a trigger, so it
|
|
81
|
+
* cannot stand in a docs page, and the canvases must still show the artifact
|
|
82
|
+
* (Katerina, D25). The stories draw these rows on a `MenuPanel`; the app gets
|
|
83
|
+
* them inside a `Menu`. One definition either way.
|
|
84
|
+
*
|
|
85
|
+
* Not exported from the package — `IdentityMenu` and `IdentityPanel` are the
|
|
86
|
+
* API; this is how they are built.
|
|
87
|
+
*/
|
|
88
|
+
export function IdentityRows({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, children }: Omit<IdentityPanelProps, 'className'> & { onClose: () => void }) {
|
|
71
89
|
const act = (action: () => void) => () => {
|
|
72
90
|
onClose()
|
|
73
91
|
action()
|
|
74
92
|
}
|
|
75
93
|
const appRows = typeof children === 'function' ? children(onClose) : children
|
|
76
94
|
return (
|
|
77
|
-
|
|
95
|
+
<>
|
|
78
96
|
<MenuSection label={signedIn ? 'Signed in as' : 'Acting as'}>
|
|
79
97
|
<MenuRow>
|
|
80
98
|
<Person name={me.name} picture={me.picture} fallback="Anonymous" size={28} className="text-body-2-strong" />
|
|
@@ -116,50 +134,59 @@ export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSig
|
|
|
116
134
|
)}
|
|
117
135
|
{onCopyKey && <MenuItem label="Copy public key" onClick={act(onCopyKey)} />}
|
|
118
136
|
{idBase && onSignOut && <MenuItem label="Sign out" onClick={act(onSignOut)} />}
|
|
119
|
-
|
|
137
|
+
</>
|
|
120
138
|
)
|
|
121
139
|
}
|
|
122
140
|
|
|
123
141
|
export function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, compact = false, className, children }: IdentityMenuProps) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
/*
|
|
143
|
+
* The menu owns the trigger (stage 4, 2026-09-08). It used to be the other
|
|
144
|
+
* way round — this component held `open`, and the panel hung from a ref to
|
|
145
|
+
* the wrapper — and four separate defects came from Base UI not knowing
|
|
146
|
+
* which element opened it: the face stopped closing its own menu, opening
|
|
147
|
+
* from the keyboard highlighted nothing, a hovered submenu row unmounted
|
|
148
|
+
* the panel, and the panel hung from a wrapper the app's layout could
|
|
149
|
+
* stretch. `PLAN.md` §6.2 has the measurements.
|
|
150
|
+
*/
|
|
151
|
+
const actions = useRef<{ close: () => void; unmount: () => void } | null>(null)
|
|
152
|
+
const close = useCallback(() => actions.current?.close(), [])
|
|
130
153
|
return (
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
154
|
+
/* The wrapper carries the caller's `className` and nothing else. It used
|
|
155
|
+
to be `relative`, because the panel was positioned against it; the panel
|
|
156
|
+
hangs from the trigger and portals now, so a positioning context here
|
|
157
|
+
would only be a lie about what this box does. */
|
|
158
|
+
<div className={className}>
|
|
159
|
+
<Menu
|
|
160
|
+
align="right"
|
|
161
|
+
actionsRef={actions}
|
|
162
|
+
className="w-72"
|
|
163
|
+
trigger={
|
|
164
|
+
<PersonTrigger
|
|
165
|
+
name={me.name}
|
|
166
|
+
picture={me.picture}
|
|
167
|
+
fallback="Anonymous"
|
|
168
|
+
compact={compact}
|
|
169
|
+
size={compact ? 36 : undefined}
|
|
170
|
+
/* The row shape is named by its own text — the person. Only the
|
|
171
|
+
bare face needs a label; naming the row would override the
|
|
172
|
+
person's name as the accessible name (Ship's tests find the
|
|
173
|
+
trigger by it). */
|
|
174
|
+
aria-label={compact ? 'Account menu' : undefined}
|
|
175
|
+
/>
|
|
176
|
+
}
|
|
177
|
+
>
|
|
178
|
+
<IdentityRows
|
|
151
179
|
me={me}
|
|
152
180
|
signedIn={signedIn}
|
|
153
181
|
relayUrl={relayUrl}
|
|
154
182
|
idBase={idBase}
|
|
155
183
|
onCopyKey={onCopyKey}
|
|
156
184
|
onSignOut={onSignOut}
|
|
157
|
-
onClose={
|
|
158
|
-
anchor={anchorRef.current}
|
|
185
|
+
onClose={close}
|
|
159
186
|
>
|
|
160
187
|
{children}
|
|
161
|
-
</
|
|
162
|
-
|
|
188
|
+
</IdentityRows>
|
|
189
|
+
</Menu>
|
|
163
190
|
</div>
|
|
164
191
|
)
|
|
165
192
|
}
|
package/src/Kbd.stories.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
2
|
import { Kbd } from './Kbd'
|
|
3
|
-
import {
|
|
3
|
+
import { MenuItem, MenuPanel, MenuSection } from './Menu'
|
|
4
4
|
import { Tooltip } from './Tooltip'
|
|
5
5
|
|
|
6
6
|
const meta = {
|
|
@@ -41,13 +41,13 @@ export const InContext: Story = {
|
|
|
41
41
|
parameters: { controls: { disable: true } },
|
|
42
42
|
render: () => (
|
|
43
43
|
<div className="flex items-start gap-6">
|
|
44
|
-
<
|
|
44
|
+
<MenuPanel className="w-[220px]">
|
|
45
45
|
<MenuSection label="Format">
|
|
46
46
|
<MenuItem label="Heading" shortcut="#" />
|
|
47
47
|
<MenuItem label="Quote" shortcut=">" />
|
|
48
48
|
<MenuItem label="Numbered list" shortcut="1." />
|
|
49
49
|
</MenuSection>
|
|
50
|
-
</
|
|
50
|
+
</MenuPanel>
|
|
51
51
|
<div className="flex flex-col gap-2">
|
|
52
52
|
<Tooltip label="Bold" shortcut="Cmd+B" />
|
|
53
53
|
<Tooltip label="Italic" shortcut="Ctrl+I" />
|
package/src/Menu.mdx
CHANGED
|
@@ -5,32 +5,29 @@ import * as MenuStories from './Menu.stories'
|
|
|
5
5
|
|
|
6
6
|
# Menu
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
8
|
+
A list of actions from a trigger. **The menu owns the trigger**, so a caller
|
|
9
|
+
writes no open state, no placement and no dismiss — it hands over the control
|
|
10
|
+
and the rows, and everything else is Base UI's.
|
|
11
|
+
|
|
12
|
+
Rows are **MenuItem**, headings are **MenuSection**, a non-interactive line at
|
|
13
|
+
row geometry is **MenuRow**, a row that opens another menu is **MenuSub**, and
|
|
14
|
+
**EnterHint** is the `↩ Enter` chip a picker row shows while highlighted.
|
|
15
|
+
|
|
16
|
+
**MenuPanel** is the container on its own, without the behaviour — for the one
|
|
17
|
+
case that cannot have it: a type-ahead popup inside a text editor, where the
|
|
18
|
+
editor's own plugin already owns the keyboard and a menu's roving focus would
|
|
19
|
+
take focus out of the text. `Menu` renders `MenuPanel`, so the surface has one
|
|
20
|
+
definition either way.
|
|
21
21
|
|
|
22
22
|
<Canvas of={MenuStories.Items} />
|
|
23
23
|
|
|
24
24
|
## When
|
|
25
25
|
|
|
26
|
-
- A click-opened list of actions: a
|
|
27
|
-
right-click's worth of verbs.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
Those rows are `MenuItem size="tall"` — content height with a 40px floor,
|
|
32
|
-
room for a 32px face via `leading` and a `description` line — with an
|
|
33
|
-
`EnterHint` on the highlighted row.
|
|
26
|
+
- A click-opened list of actions: a `⋮` more-menu, an account menu, a
|
|
27
|
+
right-click's worth of verbs.
|
|
28
|
+
- A picker fed by typing: rows are `MenuItem size="tall"` — content height
|
|
29
|
+
with a 40px floor, room for a 32px face via `leading` and a `description`
|
|
30
|
+
line — with an `EnterHint` on the highlighted row.
|
|
34
31
|
- Grouped choices where one is current: `MenuSection` over rows, `selected`
|
|
35
32
|
on the chosen one.
|
|
36
33
|
|
|
@@ -38,81 +35,97 @@ has one definition either way. If your surface opens from a trigger, you want
|
|
|
38
35
|
|
|
39
36
|
## When not
|
|
40
37
|
|
|
41
|
-
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
-
|
|
49
|
-
- Who-you-are and where-you-are (the avatar in the top bar) →
|
|
50
|
-
**IdentityMenu**, already built on this shell.
|
|
38
|
+
- **A panel of content — a field, a form, a row of controls** → **Popover**.
|
|
39
|
+
It is this surface with none of a menu's keyboard, which is what content
|
|
40
|
+
needs: a menu's typeahead would fight a text field.
|
|
41
|
+
- Picking one value for a form field → **Select**.
|
|
42
|
+
- Rows that toggle and stay open → that is a listbox, not a menu; a Checkbox
|
|
43
|
+
inside a `menuitem` is invalid. Build it as a list in a **DialogShell**.
|
|
44
|
+
- A strip of icon actions → a toolbar of **IconButton**s.
|
|
45
|
+
- Who-you-are and where-you-are → **IdentityMenu**, already built on this.
|
|
51
46
|
|
|
52
47
|
## How
|
|
53
48
|
|
|
54
|
-
Menus are controlled: the caller keeps `open` state, renders the menu only
|
|
55
|
-
while true, and hands it `onClose`. Escape and outside-click then work
|
|
56
|
-
without the caller writing either.
|
|
57
|
-
|
|
58
49
|
```tsx
|
|
59
50
|
import { Menu, MenuItem } from '@estiva-app/ui'
|
|
60
51
|
|
|
61
|
-
{
|
|
62
|
-
<
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
</Menu>
|
|
66
|
-
)}
|
|
52
|
+
<Menu trigger={<IconButton aria-label="More" tooltip="More"><IconDots /></IconButton>}>
|
|
53
|
+
<MenuItem label="Rename" onClick={rename} />
|
|
54
|
+
<MenuItem label="Delete" destructive onClick={confirmDelete} />
|
|
55
|
+
</Menu>
|
|
67
56
|
```
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
58
|
+
That is the whole of it. **No `open` state, no anchor, no `onClose`** — the
|
|
59
|
+
menu is always mounted and Base UI decides when it is shown, which is what
|
|
60
|
+
lets it place itself against the trigger, return focus to it, highlight the
|
|
61
|
+
first row when it was opened from the keyboard, and treat a second press of
|
|
62
|
+
the trigger as "close" rather than as a press outside.
|
|
63
|
+
|
|
64
|
+
- **`trigger`** is any element that forwards its ref and spreads its props —
|
|
65
|
+
this package's `Button`, `IconButton` and `PersonTrigger` all do. An
|
|
66
|
+
icon-only trigger owes a `tooltip` and an `aria-label`.
|
|
67
|
+
- **`align="right"`** hangs the menu's right edge from the trigger's, for a
|
|
68
|
+
control at the right of a row or a bar.
|
|
69
|
+
- **`open` / `onOpenChange`** are there for a caller that must know or must
|
|
70
|
+
force it. Leave them off and the menu keeps its own state.
|
|
71
|
+
- **`actionsRef`** gives `close()`, for content that is not a `MenuItem` and
|
|
72
|
+
so does not close the menu by being chosen.
|
|
73
|
+
- A menu stands as tall as the room it opens into and scrolls only when the
|
|
74
|
+
screen has none; it flips above the trigger when the room below is worse,
|
|
75
|
+
and never leaves the screen.
|
|
76
|
+
|
|
77
|
+
<Canvas of={MenuStories.OnACard} />
|
|
76
78
|
|
|
77
79
|
## Keys
|
|
78
80
|
|
|
79
81
|
| Input | What happens |
|
|
80
82
|
|---|---|
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
|
|
|
85
|
-
| Enter / Space on
|
|
86
|
-
|
|
|
83
|
+
| click / Enter / Space on the trigger | opens; from the keyboard, the first row is highlighted |
|
|
84
|
+
| ↑ / ↓ | move between rows, and wrap at the ends |
|
|
85
|
+
| Home / End | first row / last row |
|
|
86
|
+
| typing a row's first letters | jumps to that row |
|
|
87
|
+
| Enter / Space on the highlighted row | activates it, and closes the menu |
|
|
88
|
+
| → on a submenu row | opens the submenu **and highlights its first row**; ← closes it and comes back to the row |
|
|
89
|
+
| Escape | closes, and gives focus back to the trigger |
|
|
90
|
+
| a press outside, or a second press of the trigger | closes |
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
Focus moves into the menu when it opens and returns to the trigger when it
|
|
93
|
+
closes. None of that existed before stage 4: a menu was a portalled box of
|
|
94
|
+
buttons you tabbed through, and this page said the arrow keys did nothing.
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
`click` will therefore see its own press close the menu and the click
|
|
92
|
-
reopen it — a flicker that reads as "the menu ignores me". The trigger
|
|
93
|
-
swallows the mousedown, exactly as IdentityMenu's does:
|
|
96
|
+
### Hover-opened menus
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
98
|
+
`openOnHover` opens the menu on hover and closes it shortly after the pointer
|
|
99
|
+
leaves — for a control that only appears while the pointer is on a card.
|
|
100
|
+
|
|
101
|
+
**Never use it on a menu that has a `MenuSub`** — the two together strand the
|
|
102
|
+
menu open, and the component says so in the console if you try.
|
|
103
|
+
|
|
104
|
+
Measured: enter the submenu's panel, then leave in any direction that does not
|
|
105
|
+
cross back over the parent, and neither the submenu nor the menu ever closes
|
|
106
|
+
again — at 200ms, 500ms, 1s and 2s. Leaving by a path that crosses the parent
|
|
107
|
+
closes correctly, which is why it is easy to miss. Both triggers hard-code
|
|
108
|
+
Floating UI's `safePolygon({ blockPointerEvents: true })`, which blocks pointer
|
|
109
|
+
events while the path from a row to its panel is live; leaving that way never
|
|
110
|
+
resolves the polygon, and Base UI exposes no prop for it.
|
|
111
|
+
|
|
112
|
+
Without a submenu it is exact: opens on hover, closes ~200ms after the pointer
|
|
113
|
+
leaves. `PLAN.md` §6.2 carries the measurements.
|
|
101
114
|
|
|
102
|
-
###
|
|
115
|
+
### Rows without a menu
|
|
103
116
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if a second menu needs one, the shell learns it then.
|
|
117
|
+
`MenuItem` and `MenuSection` work on a bare `MenuPanel`, and there they are
|
|
118
|
+
plain buttons: no `role="menuitem"`, because ARIA requires a menu item to sit
|
|
119
|
+
inside a `menu` or a `menubar` and a `MenuPanel` is a `<div>` with no role.
|
|
120
|
+
Four surfaces used to claim menu items of nothing.
|
|
109
121
|
|
|
110
122
|
### Alignment
|
|
111
123
|
|
|
112
|
-
A menu whose rows are `px-3` (tall picker rows) aligns its headings by
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
A menu whose rows are `px-3` (tall picker rows) aligns its headings by passing
|
|
125
|
+
`className="px-3"` to `MenuSection` — the heading row is `px-2` by default,
|
|
126
|
+
matching the default rows. A `Divider` inside a menu needs nothing: the panel
|
|
127
|
+
already runs it the width of the rows it separates.
|
|
115
128
|
|
|
116
129
|
## Props
|
|
117
130
|
|
|
118
|
-
<Controls of={MenuStories.
|
|
131
|
+
<Controls of={MenuStories.FromATrigger} />
|
package/src/Menu.stories.tsx
CHANGED
|
@@ -1,89 +1,157 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { IconCopy,
|
|
2
|
+
import { IconCopy, IconDots, IconPencil, IconSquareRounded, IconTrash } from '@tabler/icons-react'
|
|
3
|
+
import { Button } from './Button'
|
|
3
4
|
import { Divider } from './Divider'
|
|
4
|
-
import {
|
|
5
|
+
import { IconButton } from './IconButton'
|
|
6
|
+
import { Menu, MenuItem, MenuPanel, MenuRow, MenuSection, MenuSub } from './Menu'
|
|
5
7
|
import { SectionLabel } from './SectionLabel'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* The shell every menu shares
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
10
|
+
* The shell every menu shares: an elevated container, rows, headings — and all
|
|
11
|
+
* of a menu's behaviour, which is Base UI's. **The menu owns its trigger**, so
|
|
12
|
+
* a caller writes no open state, no placement and no dismiss.
|
|
13
|
+
*
|
|
14
|
+
* ↑ ↓ walk the rows and wrap, Home and End jump, typing a row's first letters
|
|
15
|
+
* goes to it, → opens a submenu and ← closes it, Escape closes and gives focus
|
|
16
|
+
* back to the trigger.
|
|
17
|
+
*
|
|
18
|
+
* A live menu portals and places itself against its trigger, so it cannot
|
|
19
|
+
* stand in a docs canvas: **`Items` and `Sections` draw the surface** with
|
|
20
|
+
* `MenuPanel` — which is what a menu *is*, and what `Menu` renders — and every
|
|
21
|
+
* story below them is live (Katerina, D25).
|
|
13
22
|
*/
|
|
14
23
|
const meta = {
|
|
15
24
|
title: 'Overlays/Menu',
|
|
16
25
|
component: Menu,
|
|
17
|
-
decorators: [(Story) => <div className="flex min-h-[
|
|
18
|
-
args: {
|
|
19
|
-
argTypes: {
|
|
26
|
+
decorators: [(Story) => <div className="flex min-h-[260px] w-full items-start justify-center pt-6"><Story /></div>],
|
|
27
|
+
args: { trigger: <Button variant="outlined">Open the menu</Button>, children: null },
|
|
28
|
+
argTypes: { trigger: { control: false }, children: { control: false }, onOpenChange: { control: false }, actionsRef: { control: false } },
|
|
20
29
|
} satisfies Meta<typeof Menu>
|
|
21
30
|
|
|
22
31
|
export default meta
|
|
23
32
|
type Story = StoryObj<typeof meta>
|
|
24
33
|
|
|
25
|
-
|
|
34
|
+
const icon = (Icon: typeof IconPencil) => <Icon size={16} stroke={1.5} className="text-text-secondary" />
|
|
35
|
+
|
|
36
|
+
/** The anatomy at rest: rows with a leading icon, a shortcut, and the
|
|
37
|
+
* destructive colour for the one that deletes. */
|
|
26
38
|
export const Items: Story = {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
<MenuItem label="
|
|
39
|
+
parameters: { controls: { disable: true } },
|
|
40
|
+
render: () => (
|
|
41
|
+
<MenuPanel className="min-w-[180px]">
|
|
42
|
+
<MenuItem label="Rename" leading={icon(IconPencil)} onClick={() => {}} />
|
|
43
|
+
<MenuItem label="Duplicate" leading={icon(IconCopy)} shortcut="Ctrl+D" onClick={() => {}} />
|
|
31
44
|
<Divider className="my-1" />
|
|
32
45
|
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
|
|
33
|
-
</
|
|
46
|
+
</MenuPanel>
|
|
34
47
|
),
|
|
35
48
|
}
|
|
36
49
|
|
|
37
|
-
/** Groups under
|
|
50
|
+
/** Groups under headings, with a divider between them. The divider runs the
|
|
51
|
+
* width of the rows it separates, not the width of the panel's text. */
|
|
38
52
|
export const Sections: Story = {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<MenuItem label="
|
|
53
|
+
parameters: { controls: { disable: true } },
|
|
54
|
+
render: () => (
|
|
55
|
+
<MenuPanel className="min-w-[180px]">
|
|
56
|
+
<MenuSection label="Section">
|
|
57
|
+
<MenuItem label="Item one" selected onClick={() => {}} />
|
|
58
|
+
<MenuItem label="Item two" onClick={() => {}} />
|
|
44
59
|
</MenuSection>
|
|
45
60
|
<Divider className="my-1" />
|
|
46
|
-
<MenuSection label="
|
|
47
|
-
<MenuItem label="
|
|
48
|
-
<MenuItem label="
|
|
61
|
+
<MenuSection label="Another section">
|
|
62
|
+
<MenuItem label="Option one" onClick={() => {}} />
|
|
63
|
+
<MenuItem label="Option two" onClick={() => {}} />
|
|
49
64
|
</MenuSection>
|
|
50
|
-
</
|
|
65
|
+
</MenuPanel>
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A non-interactive line at row geometry — a value, a note. */
|
|
70
|
+
export const WithARow: Story = {
|
|
71
|
+
parameters: { controls: { disable: true } },
|
|
72
|
+
render: () => (
|
|
73
|
+
<MenuPanel className="w-64">
|
|
74
|
+
<MenuRow>
|
|
75
|
+
<span className="min-w-0 flex-1 truncate text-[14px] leading-[140%] text-text-primary">Item one</span>
|
|
76
|
+
<SectionLabel className="text-text-secondary">Label</SectionLabel>
|
|
77
|
+
</MenuRow>
|
|
78
|
+
<Divider className="my-1" />
|
|
79
|
+
<MenuItem label="An action" onClick={() => {}} />
|
|
80
|
+
</MenuPanel>
|
|
51
81
|
),
|
|
52
82
|
}
|
|
53
83
|
|
|
54
84
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
85
|
+
* Live. Click the button, then use only the keyboard: ↑ ↓ walk the rows and
|
|
86
|
+
* wrap, Home and End jump, typing "de" goes to Delete, Escape closes and puts
|
|
87
|
+
* focus back on the button.
|
|
88
|
+
*
|
|
89
|
+
* The trigger belongs to the menu, so nothing here keeps open state.
|
|
59
90
|
*/
|
|
60
|
-
export const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
<MenuItem label="
|
|
68
|
-
<MenuItem label="
|
|
69
|
-
<MenuItem label="
|
|
91
|
+
export const FromATrigger: Story = {
|
|
92
|
+
parameters: { controls: { disable: true } },
|
|
93
|
+
render: () => (
|
|
94
|
+
<Menu trigger={<Button variant="outlined">Open the menu</Button>}>
|
|
95
|
+
<MenuItem label="Rename" leading={icon(IconPencil)} onClick={() => {}} />
|
|
96
|
+
<MenuItem label="Duplicate" leading={icon(IconCopy)} shortcut="Ctrl+D" onClick={() => {}} />
|
|
97
|
+
<MenuSub label="Move to…" leading={icon(IconSquareRounded)}>
|
|
98
|
+
<MenuItem label="Item one" onClick={() => {}} />
|
|
99
|
+
<MenuItem label="Item two" onClick={() => {}} />
|
|
100
|
+
<MenuItem label="Item three" onClick={() => {}} />
|
|
70
101
|
</MenuSub>
|
|
71
102
|
<Divider className="my-1" />
|
|
72
|
-
<MenuItem label="Delete" destructive onClick={() => {}} />
|
|
103
|
+
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
|
|
73
104
|
</Menu>
|
|
74
105
|
),
|
|
75
106
|
}
|
|
76
107
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
87
|
-
|
|
108
|
+
/** The menu's right edge hangs from the trigger's, for a control at the right
|
|
109
|
+
* of a bar. */
|
|
110
|
+
export const RightAligned: Story = {
|
|
111
|
+
parameters: { controls: { disable: true }, layout: 'fullscreen' },
|
|
112
|
+
render: () => (
|
|
113
|
+
/* `items-start`: a flex row stretches its children by default, and an
|
|
114
|
+
IconButton has no height of its own — without it the 24px square grew to
|
|
115
|
+
the row's 300px and wore a 300px hover fill (measured 24x268 before). */
|
|
116
|
+
<div className="flex h-[300px] w-full items-start justify-end p-4">
|
|
117
|
+
<Menu align="right" trigger={<IconButton aria-label="More" tooltip="More"><IconDots size={16} stroke={1.5} /></IconButton>}>
|
|
118
|
+
<MenuItem label="Rename" onClick={() => {}} />
|
|
119
|
+
<MenuItem label="Delete" destructive onClick={() => {}} />
|
|
120
|
+
</Menu>
|
|
121
|
+
</div>
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The shape a card uses: a `⋮` at the end of the row, named by its tooltip,
|
|
127
|
+
* opening a right-aligned menu on **click**.
|
|
128
|
+
*
|
|
129
|
+
* Walk down the rows, out to the submenu and back — it stays open the whole
|
|
130
|
+
* way, including the diagonal from a row to its panel, and closes on Escape,
|
|
131
|
+
* on a press outside, or on a second press of the `⋮`.
|
|
132
|
+
*/
|
|
133
|
+
export const OnACard: Story = {
|
|
134
|
+
parameters: { controls: { disable: true } },
|
|
135
|
+
render: () => (
|
|
136
|
+
<div className="flex w-[420px] items-start gap-3 rounded-lg border border-border-default p-3">
|
|
137
|
+
<span className="min-w-0 flex-1 text-[14px] leading-[140%] text-text-primary">
|
|
138
|
+
A card. Press the control at the end of the row, then walk down the rows and out to the submenu.
|
|
139
|
+
</span>
|
|
140
|
+
<Menu
|
|
141
|
+
align="right"
|
|
142
|
+
trigger={<IconButton aria-label="More" tooltip="More"><IconDots size={16} stroke={1.5} /></IconButton>}
|
|
143
|
+
className="w-[244px] gap-2"
|
|
144
|
+
>
|
|
145
|
+
<MenuSection label="Section">
|
|
146
|
+
<MenuItem label="Duplicate" leading={icon(IconCopy)} onClick={() => {}} />
|
|
147
|
+
<MenuSub label="Move to…" leading={icon(IconSquareRounded)}>
|
|
148
|
+
<MenuItem label="Item one" onClick={() => {}} />
|
|
149
|
+
<MenuItem label="Item two" onClick={() => {}} />
|
|
150
|
+
</MenuSub>
|
|
151
|
+
</MenuSection>
|
|
152
|
+
<Divider className="my-1" />
|
|
153
|
+
<MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
|
|
154
|
+
</Menu>
|
|
155
|
+
</div>
|
|
88
156
|
),
|
|
89
157
|
}
|