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