@estiva-app/ui 0.8.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/stories/TokensPage.tsx +6 -2
- package/tailwind-preset.js +12 -0
- package/tokens.css +8 -0
- package/src/Menu.fit.test.ts +0 -90
- package/src/Select.fit.test.ts +0 -101
package/src/Menu.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { createContext,
|
|
1
|
+
import { createContext, useContext, type ComponentPropsWithRef, type ReactElement, type ReactNode, type RefObject } from 'react'
|
|
2
2
|
import { IconChevronRight } from '@tabler/icons-react'
|
|
3
|
-
import {
|
|
3
|
+
import { Menu as BaseMenu } from '@base-ui/react/menu'
|
|
4
4
|
import { cn } from './cn'
|
|
5
5
|
import { Kbd } from './Kbd'
|
|
6
|
-
import { clampBox, fitMenu, fitSubmenu } from './fit'
|
|
7
6
|
import { SectionLabel } from './SectionLabel'
|
|
8
7
|
|
|
9
8
|
/**
|
|
10
9
|
* THE menu shell (2026-09-01) — extracted once, for every menu in every app.
|
|
10
|
+
* On Base UI's `Menu` since stage 4 of the migration (2026-09-07).
|
|
11
11
|
*
|
|
12
12
|
* Peek has no single Menu file; its topic menu, conversation menus, files
|
|
13
13
|
* menu and the top bar's account menu each hand-roll the same thing, and
|
|
@@ -20,67 +20,55 @@ import { SectionLabel } from './SectionLabel'
|
|
|
20
20
|
* hairline border, 8px radius, 8px padding and the large shadow; items that
|
|
21
21
|
* are 8px-radius rows, `px-2 py-1.5`, hover fill, 14px text, destructive
|
|
22
22
|
* ones in the error colour; section headings as a SectionLabel in a 32px
|
|
23
|
-
* row; and the two exits every menu has — Escape and a click outside
|
|
24
|
-
* owned by the menu, never copied into a caller.
|
|
23
|
+
* row; and the two exits every menu has — Escape and a click outside.
|
|
25
24
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* the shell now portals to the body (nothing in an app can cover the body's
|
|
32
|
-
* last child at z-50) and fits itself to the viewport (fit.ts, the same
|
|
33
|
-
* measured geometry Select uses). A caller hands over a trigger, not
|
|
34
|
-
* coordinates.
|
|
25
|
+
* **What stage 4 changed, and it is the first thing a keyboard user notices:
|
|
26
|
+
* the arrow keys walk the rows.** ↑ and ↓ move between items and wrap, Home
|
|
27
|
+
* and End jump to the ends, typing a row's first letters jumps to it, and
|
|
28
|
+
* focus moves into the menu when it opens and back out when it closes. None
|
|
29
|
+
* of that existed; a menu was a portalled box of buttons you tabbed through.
|
|
35
30
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
31
|
+
* Deleted with the port: the `createPortal`, the `mousedown` listener on
|
|
32
|
+
* `document`, the Escape listener beside it, the resize and scroll listeners,
|
|
33
|
+
* the provisional hidden render this component did in order to measure
|
|
34
|
+
* itself, and `fit.ts` — the shared geometry the shell and Select both used.
|
|
35
|
+
* Floating UI places, flips and clamps all of it.
|
|
36
|
+
*
|
|
37
|
+
* The three anchorings are unchanged, and all three now portal (the third
|
|
38
|
+
* did not, which is exactly how the identity menu got covered by a z-indexed
|
|
39
|
+
* panel header in the first place):
|
|
40
|
+
* - `anchor` (an element, or a rect a click handler measured): the menu hangs
|
|
41
|
+
* under it, flips above when the room below is worse, and stays on screen.
|
|
42
|
+
* `align="right"` hangs the menu's right edge from the anchor's.
|
|
43
|
+
* - `position` (viewport coordinates the caller computed): the corner the
|
|
44
|
+
* caller chose, as a virtual anchor, so it still cannot land off screen.
|
|
45
|
+
* - neither: under the element the menu sits in, right-aligned — what
|
|
46
|
+
* `absolute right-0 top-full mt-1` drew, but portalled, so no ancestor's
|
|
47
|
+
* stacking context or overflow can clip it.
|
|
50
48
|
*/
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
* leaving. */
|
|
64
|
-
closeOnLeave?: boolean
|
|
65
|
-
children: ReactNode
|
|
66
|
-
className?: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** MenuSub reports its hover into the enclosing Menu's leave-grace timer, so
|
|
70
|
-
* a `closeOnLeave` menu survives the pointer crossing into a portalled
|
|
71
|
-
* submenu panel — the one hover region the old inline submenus had for free. */
|
|
72
|
-
const MenuHoverContext = createContext<{ hold: () => void; release: () => void } | null>(null)
|
|
49
|
+
/**
|
|
50
|
+
* Whether a row is inside a `Menu`.
|
|
51
|
+
*
|
|
52
|
+
* It is load-bearing: `MenuItem` and `MenuSection` are also used
|
|
53
|
+
* inside a bare `MenuPanel`, with no `Menu` around them — Peek's `@`, `/` and
|
|
54
|
+
* `[` pickers and its compose menu all do that, because a popup inside a text
|
|
55
|
+
* editor cannot have a menu's keyboard: the editor's suggestion plugin already
|
|
56
|
+
* owns it. A Base UI `Menu.Item` outside a `Menu.Root` has no context to read,
|
|
57
|
+
* so those rows stay exactly the buttons they have always been, and only rows
|
|
58
|
+
* inside a real menu become the part.
|
|
59
|
+
*/
|
|
60
|
+
const MenuContext = createContext<{ openOnHover: boolean } | null>(null)
|
|
73
61
|
|
|
74
62
|
/**
|
|
75
63
|
* The menu's surface, with none of its behaviour — an elevated box with a
|
|
76
64
|
* hairline border, 8px radius, 8px padding and the large shadow.
|
|
77
65
|
*
|
|
78
|
-
* Split out of `Menu` on 2026-09-05. `Menu` owns Escape, outside-click
|
|
79
|
-
* placement, and that is right for a menu opened from a
|
|
80
|
-
* type-ahead popup inside a text editor cannot have them: the
|
|
81
|
-
* suggestion plugin already owns the keyboard and positions the
|
|
82
|
-
* second Escape handler fights it. So Peek's `@`, `/` and `[`
|
|
83
|
-
* this box by hand, and the three had already drifted apart.
|
|
66
|
+
* Split out of `Menu` on 2026-09-05. `Menu` owns Escape, outside-click,
|
|
67
|
+
* placement and now the keyboard, and that is right for a menu opened from a
|
|
68
|
+
* trigger — but a type-ahead popup inside a text editor cannot have them: the
|
|
69
|
+
* editor's suggestion plugin already owns the keyboard and positions the
|
|
70
|
+
* popup, and a second Escape handler fights it. So Peek's `@`, `/` and `[`
|
|
71
|
+
* menus each drew this box by hand, and the three had already drifted apart.
|
|
84
72
|
*
|
|
85
73
|
* `Menu` renders this, so there is still exactly one definition of the
|
|
86
74
|
* surface — change it here and every menu in every app follows.
|
|
@@ -89,13 +77,31 @@ const MenuHoverContext = createContext<{ hold: () => void; release: () => void }
|
|
|
89
77
|
* people is not the width of one that lists verbs.
|
|
90
78
|
*/
|
|
91
79
|
export interface MenuPanelProps extends Omit<ComponentPropsWithRef<'div'>, 'children'> {
|
|
92
|
-
|
|
80
|
+
/** Optional only because Base UI fills it in when this is a `render`
|
|
81
|
+
* target: `Menu.Popup` supplies the children. Every other caller passes
|
|
82
|
+
* them. */
|
|
83
|
+
children?: ReactNode
|
|
93
84
|
}
|
|
94
85
|
|
|
95
86
|
export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
|
|
96
87
|
return (
|
|
97
88
|
<div
|
|
98
|
-
className={cn(
|
|
89
|
+
className={cn(
|
|
90
|
+
'flex flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg',
|
|
91
|
+
/*
|
|
92
|
+
* A divider in a menu runs the width of the rows it separates.
|
|
93
|
+
*
|
|
94
|
+
* `Divider` is inset 12px each side, which is right in a page and
|
|
95
|
+
* wrong here: measured in a 180px menu, the rule started 21px from the
|
|
96
|
+
* panel's edge where the rows start at 9px — 12px narrower on each side
|
|
97
|
+
* than the things it divides. `IdentityMenu` was already cancelling it
|
|
98
|
+
* by hand with `mx-0`, which is the sign it belonged here (Katerina,
|
|
99
|
+
* 2026-09-08). Direct children only, so a divider a caller puts inside
|
|
100
|
+
* a row keeps its own spacing.
|
|
101
|
+
*/
|
|
102
|
+
'[&>[role=separator]]:mx-0',
|
|
103
|
+
className,
|
|
104
|
+
)}
|
|
99
105
|
{...props}
|
|
100
106
|
>
|
|
101
107
|
{children}
|
|
@@ -103,107 +109,107 @@ export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
|
|
|
103
109
|
)
|
|
104
110
|
}
|
|
105
111
|
|
|
106
|
-
export
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
112
|
+
export interface MenuProps {
|
|
113
|
+
/**
|
|
114
|
+
* The control that opens the menu. Any element that forwards its ref and
|
|
115
|
+
* spreads its props — this package's `Button`, `IconButton` and
|
|
116
|
+
* `PersonTrigger` all do.
|
|
117
|
+
*
|
|
118
|
+
* The menu owns it, and that is the point: Base UI can only do the toggle,
|
|
119
|
+
* the placement, the focus return, the first-item highlight and the whole
|
|
120
|
+
* hover choreography if it knows which element opened it.
|
|
121
|
+
*/
|
|
122
|
+
trigger: ReactElement
|
|
123
|
+
/** Which of the menu's edges hangs from the trigger's. Default left. */
|
|
124
|
+
align?: 'left' | 'right'
|
|
125
|
+
/**
|
|
126
|
+
* Open on hover, and close shortly after the pointer leaves — for a control
|
|
127
|
+
* that only appears while the pointer is on a card.
|
|
128
|
+
*
|
|
129
|
+
* Base UI owns the whole choreography, including the diagonal from a row
|
|
130
|
+
* out to a submenu panel. The shell used to hand-write this and got it
|
|
131
|
+
* wrong in two directions.
|
|
132
|
+
*/
|
|
133
|
+
openOnHover?: boolean
|
|
134
|
+
/** Controlled, for a caller that must know or must force it. Leave both
|
|
135
|
+
* off and the menu keeps its own state. */
|
|
136
|
+
open?: boolean
|
|
137
|
+
onOpenChange?: (open: boolean) => void
|
|
138
|
+
/** Base UI's imperative handle. `actions.current?.close()` shuts the menu —
|
|
139
|
+
* for a row that must close it before opening what it opens. A `MenuItem`
|
|
140
|
+
* closes the menu by itself, so this is only for content that is not one. */
|
|
141
|
+
actionsRef?: RefObject<{ close: () => void; unmount: () => void } | null>
|
|
142
|
+
children: ReactNode
|
|
143
|
+
/** On the menu's surface — its width, its internal rhythm. */
|
|
144
|
+
className?: string
|
|
145
|
+
}
|
|
135
146
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Read from `globalThis` rather than as a bare `process`: the declaration
|
|
149
|
+
* build carries no ambient types on purpose, so that a consumer of this
|
|
150
|
+
* package does not inherit Node's. Every bundler still replaces the value.
|
|
151
|
+
*/
|
|
152
|
+
const isProduction = () =>
|
|
153
|
+
(globalThis as { process?: { env?: { NODE_ENV?: string } } }).process?.env?.NODE_ENV === 'production'
|
|
142
154
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
fitMenu({
|
|
152
|
-
anchor: { left, top: rect.top, bottom: rect.bottom },
|
|
153
|
-
menu: { width: menu.offsetWidth, contentHeight: menu.scrollHeight },
|
|
154
|
-
viewport,
|
|
155
|
-
}),
|
|
156
|
-
)
|
|
157
|
-
} else if (posTop !== undefined) {
|
|
158
|
-
const left = posLeft ?? viewport.width - (posRight ?? 0) - menu.offsetWidth
|
|
159
|
-
setPlaced(clampBox({ box: { left, top: posTop, width: menu.offsetWidth, height: menu.offsetHeight }, viewport }))
|
|
160
|
-
}
|
|
161
|
-
}, [portalled, anchor, align, posLeft, posRight, posTop])
|
|
155
|
+
/** The 4px between a menu and its trigger, and the 8px it keeps clear of
|
|
156
|
+
* every screen edge — the two numbers `fit.ts` used. */
|
|
157
|
+
const GAP = 4
|
|
158
|
+
const VIEWPORT_PAD = 8
|
|
159
|
+
/** A hover menu opens at once and closes 150ms after the pointer leaves —
|
|
160
|
+
* the two numbers the hand-written version used. */
|
|
161
|
+
const HOVER_OPEN_DELAY = 0
|
|
162
|
+
const HOVER_CLOSE_DELAY = 150
|
|
162
163
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
164
|
+
export function Menu({ trigger, align = 'left', openOnHover = false, open, onOpenChange, actionsRef, children, className }: MenuProps) {
|
|
165
|
+
return (
|
|
166
|
+
<BaseMenu.Root
|
|
167
|
+
open={open}
|
|
168
|
+
onOpenChange={onOpenChange ? (next) => onOpenChange(next) : undefined}
|
|
169
|
+
actionsRef={actionsRef}
|
|
170
|
+
/* Non-modal, as every menu here has always been: the page behind keeps
|
|
171
|
+
its scrollbar, so opening a menu never shifts the layout. */
|
|
172
|
+
modal={false}
|
|
173
|
+
>
|
|
174
|
+
<BaseMenu.Trigger
|
|
175
|
+
render={trigger}
|
|
176
|
+
openOnHover={openOnHover}
|
|
177
|
+
delay={HOVER_OPEN_DELAY}
|
|
178
|
+
closeDelay={HOVER_CLOSE_DELAY}
|
|
179
|
+
/>
|
|
180
|
+
<BaseMenu.Portal>
|
|
181
|
+
<BaseMenu.Positioner
|
|
182
|
+
side="bottom"
|
|
183
|
+
align={align === 'right' ? 'end' : 'start'}
|
|
184
|
+
sideOffset={GAP}
|
|
185
|
+
collisionPadding={VIEWPORT_PAD}
|
|
186
|
+
className="z-50 data-[anchor-hidden]:hidden"
|
|
187
|
+
>
|
|
188
|
+
<BaseMenu.Popup
|
|
189
|
+
data-interactive
|
|
190
|
+
/* `outline-none`: the popup is a programmatic focus target, not
|
|
191
|
+
something a keyboard user tabs to. Without it Chrome rings the
|
|
192
|
+
WHOLE panel when the menu is opened from the keyboard, which
|
|
193
|
+
reads as "the menu is one thing" rather than "these rows are the
|
|
194
|
+
things" — measured, `outline: auto 1px`. The rows keep their own
|
|
195
|
+
highlight. Exactly the fix `DialogShell`'s card needed at stage
|
|
196
|
+
3, in a second place.
|
|
179
197
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
portalled ? 'fixed overflow-y-auto' : 'absolute right-0 top-full mt-1',
|
|
195
|
-
className,
|
|
196
|
-
)}
|
|
197
|
-
style={style}
|
|
198
|
-
onClick={(event) => event.stopPropagation()}
|
|
199
|
-
onMouseEnter={closeOnLeave ? hold : undefined}
|
|
200
|
-
onMouseLeave={closeOnLeave ? release : undefined}
|
|
201
|
-
>
|
|
202
|
-
{children}
|
|
203
|
-
</MenuPanel>
|
|
204
|
-
</MenuHoverContext.Provider>
|
|
198
|
+
`--available-height` is the room Floating UI found after flipping
|
|
199
|
+
and clamping; the shell passes no cap of its own, so a menu stands
|
|
200
|
+
as tall as it can and scrolls only when the screen truly has no
|
|
201
|
+
room. Select's 288 was never this component's — the identity
|
|
202
|
+
panel got it by accident once and grew a scrollbar at full
|
|
203
|
+
height. */
|
|
204
|
+
className={cn('min-w-[180px] max-h-[var(--available-height)] overflow-y-auto outline-none', className)}
|
|
205
|
+
render={<MenuPanel />}
|
|
206
|
+
>
|
|
207
|
+
<MenuContext.Provider value={{ openOnHover }}>{children}</MenuContext.Provider>
|
|
208
|
+
</BaseMenu.Popup>
|
|
209
|
+
</BaseMenu.Positioner>
|
|
210
|
+
</BaseMenu.Portal>
|
|
211
|
+
</BaseMenu.Root>
|
|
205
212
|
)
|
|
206
|
-
return portalled ? createPortal(node, document.body) : node
|
|
207
213
|
}
|
|
208
214
|
|
|
209
215
|
/**
|
|
@@ -211,11 +217,11 @@ export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave =
|
|
|
211
217
|
* hand-rolled before this, one of which dropped the ref its edge-flip
|
|
212
218
|
* measured and shipped a submenu cut off by the screen (2026-09-03).
|
|
213
219
|
*
|
|
214
|
-
* Hover-timed
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
220
|
+
* Hover-timed as those were, and as ours was: it opens at once and closes
|
|
221
|
+
* 150ms after the pointer leaves, so the diagonal from row to panel survives.
|
|
222
|
+
* Those two numbers are all that is left of the old implementation — the
|
|
223
|
+
* placement, the flip at a screen edge, and now the arrow keys (→ opens it,
|
|
224
|
+
* ← closes it) are Base UI's.
|
|
219
225
|
*/
|
|
220
226
|
export interface MenuSubProps {
|
|
221
227
|
/** The trigger row's label. */
|
|
@@ -230,60 +236,54 @@ export interface MenuSubProps {
|
|
|
230
236
|
}
|
|
231
237
|
|
|
232
238
|
export function MenuSub({ label, leading, selected, children, className }: MenuSubProps) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
useLayoutEffect(() => {
|
|
254
|
-
if (!open || !rowRef.current || !panelRef.current) {
|
|
255
|
-
setPlaced(null)
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
const row = rowRef.current.getBoundingClientRect()
|
|
259
|
-
setPlaced(
|
|
260
|
-
fitSubmenu({
|
|
261
|
-
row: { left: row.left, right: row.right, top: row.top },
|
|
262
|
-
panel: { width: panelRef.current.offsetWidth, height: panelRef.current.offsetHeight },
|
|
263
|
-
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
264
|
-
}),
|
|
239
|
+
/*
|
|
240
|
+
* A submenu inside a hover-opened menu strands it.
|
|
241
|
+
*
|
|
242
|
+
* Measured 2026-09-08: enter the submenu's panel, then leave in any
|
|
243
|
+
* direction that does not cross back over the parent, and neither the
|
|
244
|
+
* submenu nor the menu ever closes again — at 200ms, 500ms, 1s and 2s.
|
|
245
|
+
* Both triggers hard-code Floating UI's `safePolygon({ blockPointerEvents:
|
|
246
|
+
* true })`, which blocks pointer events while the path from row to panel is
|
|
247
|
+
* live; leaving that way never resolves the polygon. There is no prop.
|
|
248
|
+
*
|
|
249
|
+
* So the rule is: no `openOnHover` on a menu that has one of these. This
|
|
250
|
+
* says so out loud rather than leaving it to the page, because the failure
|
|
251
|
+
* is a menu that will not go away and the cause is two files apart.
|
|
252
|
+
*/
|
|
253
|
+
const menu = useContext(MenuContext)
|
|
254
|
+
if (!isProduction() && menu?.openOnHover) {
|
|
255
|
+
console.error(
|
|
256
|
+
'[@estiva-app/ui] Menu: `openOnHover` and `MenuSub` cannot be used together — ' +
|
|
257
|
+
'leaving the submenu panel strands the menu open. Open this menu on a press instead. ' +
|
|
258
|
+
'See Menu.mdx, "Hover-opened menus".',
|
|
265
259
|
)
|
|
266
|
-
}
|
|
267
|
-
|
|
260
|
+
}
|
|
268
261
|
return (
|
|
269
|
-
<
|
|
270
|
-
<
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
262
|
+
<BaseMenu.SubmenuRoot>
|
|
263
|
+
<BaseMenu.SubmenuTrigger
|
|
264
|
+
openOnHover
|
|
265
|
+
delay={0}
|
|
266
|
+
closeDelay={150}
|
|
267
|
+
nativeButton
|
|
268
|
+
render={<button type="button" />}
|
|
269
|
+
className={menuItemClassName({ size: 'default', selected })}
|
|
270
|
+
>
|
|
271
|
+
<MenuItemBody label={label} leading={leading} submenu />
|
|
272
|
+
</BaseMenu.SubmenuTrigger>
|
|
273
|
+
<BaseMenu.Portal>
|
|
274
|
+
<BaseMenu.Positioner
|
|
275
|
+
side="inline-end"
|
|
276
|
+
align="start"
|
|
277
|
+
sideOffset={GAP}
|
|
278
|
+
collisionPadding={VIEWPORT_PAD}
|
|
279
|
+
className="z-50 data-[anchor-hidden]:hidden"
|
|
280
|
+
>
|
|
281
|
+
<BaseMenu.Popup className={cn('w-[160px]', className)} data-interactive render={<MenuPanel />}>
|
|
282
282
|
{children}
|
|
283
|
-
</
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
</
|
|
283
|
+
</BaseMenu.Popup>
|
|
284
|
+
</BaseMenu.Positioner>
|
|
285
|
+
</BaseMenu.Portal>
|
|
286
|
+
</BaseMenu.SubmenuRoot>
|
|
287
287
|
)
|
|
288
288
|
}
|
|
289
289
|
|
|
@@ -328,7 +328,43 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
|
|
|
328
328
|
selected?: boolean
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
-
|
|
331
|
+
/**
|
|
332
|
+
* The row's class list, written once because `MenuSub`'s trigger row is the
|
|
333
|
+
* same row.
|
|
334
|
+
*
|
|
335
|
+
* `data-highlighted` joins `:hover` here, and that is the whole visible
|
|
336
|
+
* consequence of the port: the fill that followed the pointer now also
|
|
337
|
+
* follows the arrow keys, because Base UI sets one attribute for both.
|
|
338
|
+
*/
|
|
339
|
+
function menuItemClassName({ size, selected, className }: { size: 'default' | 'tall'; selected?: boolean; className?: string }) {
|
|
340
|
+
return cn(
|
|
341
|
+
// shrink-0: a menu is a flex column that scrolls at its max height,
|
|
342
|
+
// and a flex child shrinks before its container does — so every row
|
|
343
|
+
// in an overflowing menu was squashed to its `min-h`, and a row given
|
|
344
|
+
// an explicit height silently lost it (Peek's `[` menu: h-12 rows
|
|
345
|
+
// measured 40px). The same fix NavItem took on 2026-09-02.
|
|
346
|
+
// `group`: the `hint` slot reveals itself from this row's own :hover,
|
|
347
|
+
// so the hint and the fill are one CSS state change, not two engines.
|
|
348
|
+
//
|
|
349
|
+
// No transition on the fill (Katerina, 2026-09-05). It faded over
|
|
350
|
+
// 150ms, and anything appearing with it had to fade too or arrive
|
|
351
|
+
// ahead of it — which, sweeping a pointer down a list, read as the
|
|
352
|
+
// hint flickering in and out. Both are instant now: they still change
|
|
353
|
+
// on exactly the same :hover, so they cannot come apart, and a row
|
|
354
|
+
// lights and unlights crisply as the pointer crosses it.
|
|
355
|
+
'group flex w-full shrink-0 cursor-pointer items-center rounded-lg text-left outline-none hover:bg-bg-hover data-[highlighted]:bg-bg-hover',
|
|
356
|
+
// tall: as tall as its content, never shorter than 40px (Katerina,
|
|
357
|
+
// 2026-09-01) — a single-line picker row sits at 40, a row with a
|
|
358
|
+
// 32px face and a role line comes out at its natural 48. One rule,
|
|
359
|
+
// not a hand-picked height per file.
|
|
360
|
+
size === 'tall' ? 'min-h-10 gap-3 px-3 py-1.5' : 'gap-2 px-2 py-1.5',
|
|
361
|
+
selected && 'bg-bg-hover',
|
|
362
|
+
className,
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Everything inside the row — written once, for the same reason. */
|
|
367
|
+
function MenuItemBody({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected }: Pick<MenuItemProps, 'label' | 'children' | 'size' | 'description' | 'leading' | 'trailing' | 'hint' | 'shortcut' | 'submenu' | 'destructive' | 'selected'>) {
|
|
332
368
|
const edge =
|
|
333
369
|
trailing ??
|
|
334
370
|
(shortcut ? (
|
|
@@ -337,35 +373,7 @@ export function MenuItem({ label, children, size = 'default', description, leadi
|
|
|
337
373
|
<IconChevronRight size={16} stroke={1.5} className="shrink-0 text-text-muted" />
|
|
338
374
|
) : null)
|
|
339
375
|
return (
|
|
340
|
-
|
|
341
|
-
type="button"
|
|
342
|
-
role="menuitem"
|
|
343
|
-
className={cn(
|
|
344
|
-
// shrink-0: a menu is a flex column that scrolls at its max height,
|
|
345
|
-
// and a flex child shrinks before its container does — so every row
|
|
346
|
-
// in an overflowing menu was squashed to its `min-h`, and a row given
|
|
347
|
-
// an explicit height silently lost it (Peek's `[` menu: h-12 rows
|
|
348
|
-
// measured 40px). The same fix NavItem took on 2026-09-02.
|
|
349
|
-
// `group`: the `hint` slot reveals itself from this row's own :hover,
|
|
350
|
-
// so the hint and the fill are one CSS state change, not two engines.
|
|
351
|
-
//
|
|
352
|
-
// No transition on the fill (Katerina, 2026-09-05). It faded over
|
|
353
|
-
// 150ms, and anything appearing with it had to fade too or arrive
|
|
354
|
-
// ahead of it — which, sweeping a pointer down a list, read as the
|
|
355
|
-
// hint flickering in and out. Both are instant now: they still change
|
|
356
|
-
// on exactly the same :hover, so they cannot come apart, and a row
|
|
357
|
-
// lights and unlights crisply as the pointer crosses it.
|
|
358
|
-
'group flex w-full shrink-0 cursor-pointer items-center rounded-lg text-left hover:bg-bg-hover',
|
|
359
|
-
// tall: as tall as its content, never shorter than 40px (Katerina,
|
|
360
|
-
// 2026-09-01) — a single-line picker row sits at 40, a row with a
|
|
361
|
-
// 32px face and a role line comes out at its natural 48. One rule,
|
|
362
|
-
// not a hand-picked height per file.
|
|
363
|
-
size === 'tall' ? 'min-h-10 gap-3 px-3 py-1.5' : 'gap-2 px-2 py-1.5',
|
|
364
|
-
selected && 'bg-bg-hover',
|
|
365
|
-
className,
|
|
366
|
-
)}
|
|
367
|
-
{...props}
|
|
368
|
-
>
|
|
376
|
+
<>
|
|
369
377
|
{leading && <span className="flex shrink-0 items-center">{leading}</span>}
|
|
370
378
|
{/* Sizes are arbitrary values (the body-2 and caption tokens): these
|
|
371
379
|
lists merge with a colour, and tw-merge drops a token size beside a
|
|
@@ -385,18 +393,80 @@ export function MenuItem({ label, children, size = 'default', description, leadi
|
|
|
385
393
|
// :hover / `selected` as the fill, in the same frame.
|
|
386
394
|
<span className="grid shrink-0 items-center justify-items-end [&>*]:col-start-1 [&>*]:row-start-1">
|
|
387
395
|
{edge && (
|
|
388
|
-
<span className={cn('flex items-center', hint && 'group-hover:opacity-0', hint && selected && 'opacity-0')}>
|
|
396
|
+
<span className={cn('flex items-center', hint && 'group-hover:opacity-0 group-data-[highlighted]:opacity-0', hint && selected && 'opacity-0')}>
|
|
389
397
|
{edge}
|
|
390
398
|
</span>
|
|
391
399
|
)}
|
|
392
400
|
{hint && (
|
|
393
|
-
<span className={cn('flex items-center opacity-0 group-hover:opacity-100', selected && 'opacity-100')}>
|
|
401
|
+
<span className={cn('flex items-center opacity-0 group-hover:opacity-100 group-data-[highlighted]:opacity-100', selected && 'opacity-100')}>
|
|
394
402
|
{hint}
|
|
395
403
|
</span>
|
|
396
404
|
)}
|
|
397
405
|
</span>
|
|
398
406
|
)}
|
|
399
|
-
|
|
407
|
+
</>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function MenuItem({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
|
|
412
|
+
const body = (
|
|
413
|
+
<MenuItemBody
|
|
414
|
+
label={label}
|
|
415
|
+
size={size}
|
|
416
|
+
description={description}
|
|
417
|
+
leading={leading}
|
|
418
|
+
trailing={trailing}
|
|
419
|
+
hint={hint}
|
|
420
|
+
shortcut={shortcut}
|
|
421
|
+
submenu={submenu}
|
|
422
|
+
destructive={destructive}
|
|
423
|
+
selected={selected}
|
|
424
|
+
>
|
|
425
|
+
{children}
|
|
426
|
+
</MenuItemBody>
|
|
427
|
+
)
|
|
428
|
+
const rowClassName = menuItemClassName({ size, selected, className })
|
|
429
|
+
|
|
430
|
+
/*
|
|
431
|
+
* Inside a `Menu` the row is Base UI's `Menu.Item`: it joins the roving
|
|
432
|
+
* focus, answers the typeahead, and closes the menu when it is chosen.
|
|
433
|
+
* `nativeButton` keeps the `<button>`; the part would draw a `<div>`.
|
|
434
|
+
*
|
|
435
|
+
* Outside one — a bare `MenuPanel` in an editor popup — it is a plain
|
|
436
|
+
* button, **with no `role="menuitem"`**. It used to carry the role
|
|
437
|
+
* unconditionally, which made an orphan: ARIA requires a `menuitem` to sit
|
|
438
|
+
* inside a `menu` or a `menubar`, and `MenuPanel` is a `<div>` with no role
|
|
439
|
+
* at all. Four Peek files draw rows that way, so four surfaces were telling
|
|
440
|
+
* a screen reader they were menu items of nothing.
|
|
441
|
+
*
|
|
442
|
+
* A button is what these rows actually are. The pickers they sit in are a
|
|
443
|
+
* listbox pattern rather than a menu — the editor's plugin owns the
|
|
444
|
+
* highlight and the keyboard — and saying so properly is `ChipInput`'s
|
|
445
|
+
* stage, not this one; claiming the wrong role in the meantime is worse
|
|
446
|
+
* than claiming none.
|
|
447
|
+
*/
|
|
448
|
+
if (!useContext(MenuContext)) {
|
|
449
|
+
return (
|
|
450
|
+
<button type="button" className={rowClassName} {...props}>
|
|
451
|
+
{body}
|
|
452
|
+
</button>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
return (
|
|
456
|
+
<BaseMenu.Item
|
|
457
|
+
nativeButton
|
|
458
|
+
render={<button type="button" />}
|
|
459
|
+
className={rowClassName}
|
|
460
|
+
/* `Menu.Item` types its handlers for the `<div>` it would draw by
|
|
461
|
+
default. `render` makes it the `<button>` this row has always been,
|
|
462
|
+
and `nativeButton` tells Base UI so — but the prop types do not
|
|
463
|
+
follow the render target, so a caller's `onClick` is a button
|
|
464
|
+
handler facing a div signature. The element underneath is a
|
|
465
|
+
`<button>`, which `Menu.test.tsx` pins. */
|
|
466
|
+
{...(props as ComponentPropsWithRef<'div'>)}
|
|
467
|
+
>
|
|
468
|
+
{body}
|
|
469
|
+
</BaseMenu.Item>
|
|
400
470
|
)
|
|
401
471
|
}
|
|
402
472
|
|
|
@@ -428,17 +498,37 @@ export function EnterHint({ target }: { target?: string }) {
|
|
|
428
498
|
)
|
|
429
499
|
}
|
|
430
500
|
|
|
431
|
-
/**
|
|
432
|
-
*
|
|
433
|
-
*
|
|
501
|
+
/**
|
|
502
|
+
* A section heading inside a menu: the 32px row with a SectionLabel, read
|
|
503
|
+
* secondary — a heading inside a menu labels the rows, it is not one of
|
|
504
|
+
* them (Katerina, 2026-09-01).
|
|
505
|
+
*
|
|
506
|
+
* Inside a `Menu` it is Base UI's `Menu.Group` with the heading as its
|
|
507
|
+
* `GroupLabel`, so the rows under it are announced as a named group rather
|
|
508
|
+
* than as a run of items with a stray line above them. Outside one — the
|
|
509
|
+
* editor pickers, which draw the surface but not the menu — it is the two
|
|
510
|
+
* plain elements it has always been.
|
|
511
|
+
*/
|
|
434
512
|
export function MenuSection({ label, children, className }: { label: string; children: ReactNode; /** On the heading row — a surface whose rows are px-3 aligns its heading with px-3. */ className?: string }) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
513
|
+
const inMenu = useContext(MenuContext) !== null
|
|
514
|
+
const heading = (
|
|
515
|
+
<div className={cn('flex h-8 items-center px-2', className)}>
|
|
516
|
+
<SectionLabel className="text-text-secondary">{label}</SectionLabel>
|
|
517
|
+
</div>
|
|
518
|
+
)
|
|
519
|
+
if (!inMenu) {
|
|
520
|
+
return (
|
|
521
|
+
<div className="flex flex-col">
|
|
522
|
+
{heading}
|
|
523
|
+
{children}
|
|
439
524
|
</div>
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
return (
|
|
528
|
+
<BaseMenu.Group className="flex flex-col">
|
|
529
|
+
<BaseMenu.GroupLabel render={heading} />
|
|
440
530
|
{children}
|
|
441
|
-
</
|
|
531
|
+
</BaseMenu.Group>
|
|
442
532
|
)
|
|
443
533
|
}
|
|
444
534
|
|