@estiva-app/ui 0.9.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/Avatar.d.ts +13 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts.map +1 -1
  4. package/dist/Banner.d.ts +16 -1
  5. package/dist/Banner.d.ts.map +1 -1
  6. package/dist/Breadcrumb.d.ts +9 -0
  7. package/dist/Breadcrumb.d.ts.map +1 -1
  8. package/dist/Button.d.ts +12 -2
  9. package/dist/Button.d.ts.map +1 -1
  10. package/dist/Checkbox.d.ts +2 -0
  11. package/dist/Checkbox.d.ts.map +1 -1
  12. package/dist/Chip.d.ts +3 -2
  13. package/dist/Chip.d.ts.map +1 -1
  14. package/dist/ChipInput.d.ts +3 -1
  15. package/dist/ChipInput.d.ts.map +1 -1
  16. package/dist/ConfirmDialog.d.ts +5 -0
  17. package/dist/ConfirmDialog.d.ts.map +1 -1
  18. package/dist/DialogShell.d.ts +27 -3
  19. package/dist/DialogShell.d.ts.map +1 -1
  20. package/dist/EditableText.d.ts +6 -0
  21. package/dist/EditableText.d.ts.map +1 -1
  22. package/dist/Field.d.ts +42 -25
  23. package/dist/Field.d.ts.map +1 -1
  24. package/dist/IconButton.d.ts +3 -2
  25. package/dist/IconButton.d.ts.map +1 -1
  26. package/dist/IdentityMenu.d.ts +25 -13
  27. package/dist/IdentityMenu.d.ts.map +1 -1
  28. package/dist/Menu.d.ts +65 -82
  29. package/dist/Menu.d.ts.map +1 -1
  30. package/dist/PersonTrigger.d.ts.map +1 -1
  31. package/dist/Popover.d.ts +86 -0
  32. package/dist/Popover.d.ts.map +1 -0
  33. package/dist/PreviewCard.d.ts +39 -0
  34. package/dist/PreviewCard.d.ts.map +1 -0
  35. package/dist/Reaction.d.ts.map +1 -1
  36. package/dist/ReactionPicker.d.ts +55 -0
  37. package/dist/ReactionPicker.d.ts.map +1 -0
  38. package/dist/SearchInput.d.ts +5 -0
  39. package/dist/SearchInput.d.ts.map +1 -1
  40. package/dist/Select.d.ts +26 -16
  41. package/dist/Select.d.ts.map +1 -1
  42. package/dist/Tabs.d.ts +11 -1
  43. package/dist/Tabs.d.ts.map +1 -1
  44. package/dist/TextInput.d.ts +7 -1
  45. package/dist/TextInput.d.ts.map +1 -1
  46. package/dist/Textarea.d.ts +8 -1
  47. package/dist/Textarea.d.ts.map +1 -1
  48. package/dist/Toolbar.d.ts +95 -0
  49. package/dist/Toolbar.d.ts.map +1 -0
  50. package/dist/Tooltip.d.ts +36 -10
  51. package/dist/Tooltip.d.ts.map +1 -1
  52. package/dist/cn.d.ts.map +1 -1
  53. package/dist/fit.d.ts +7 -72
  54. package/dist/fit.d.ts.map +1 -1
  55. package/dist/index.d.ts +6 -3
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +888 -763
  58. package/dist/index.js.map +4 -4
  59. package/dist/triggerDisabled.d.ts +10 -0
  60. package/dist/triggerDisabled.d.ts.map +1 -0
  61. package/package.json +1 -1
  62. package/src/Avatar.mdx +17 -0
  63. package/src/Avatar.name.test.tsx +99 -0
  64. package/src/Avatar.tsx +25 -3
  65. package/src/AvatarGroup.tsx +4 -1
  66. package/src/Banner.mdx +10 -2
  67. package/src/Banner.stories.tsx +29 -0
  68. package/src/Banner.test.tsx +60 -0
  69. package/src/Banner.tsx +35 -3
  70. package/src/Breadcrumb.mdx +5 -2
  71. package/src/Breadcrumb.test.tsx +44 -0
  72. package/src/Breadcrumb.tsx +10 -2
  73. package/src/Button.compose.test.tsx +119 -0
  74. package/src/Button.mdx +7 -5
  75. package/src/Button.test.tsx +2 -1
  76. package/src/Button.tsx +21 -5
  77. package/src/Checkbox.tsx +3 -0
  78. package/src/Chip.tsx +3 -2
  79. package/src/ChipInput.tsx +4 -0
  80. package/src/ConfirmDialog.mdx +20 -1
  81. package/src/ConfirmDialog.test.tsx +109 -0
  82. package/src/ConfirmDialog.tsx +6 -0
  83. package/src/DialogShell.mdx +18 -4
  84. package/src/DialogShell.stories.tsx +56 -6
  85. package/src/DialogShell.test.tsx +184 -0
  86. package/src/DialogShell.tsx +108 -39
  87. package/src/EditableText.mdx +6 -1
  88. package/src/EditableText.test.tsx +156 -0
  89. package/src/EditableText.tsx +23 -5
  90. package/src/Field.mdx +23 -3
  91. package/src/Field.stories.tsx +35 -0
  92. package/src/Field.test.tsx +144 -26
  93. package/src/Field.tsx +94 -59
  94. package/src/IconButton.mdx +6 -2
  95. package/src/IconButton.test.tsx +5 -2
  96. package/src/IconButton.tsx +12 -6
  97. package/src/IdentityMenu.mdx +29 -6
  98. package/src/IdentityMenu.stories.tsx +41 -6
  99. package/src/IdentityMenu.tsx +74 -47
  100. package/src/Kbd.stories.tsx +3 -3
  101. package/src/Menu.mdx +88 -75
  102. package/src/Menu.stories.tsx +120 -52
  103. package/src/Menu.test.tsx +315 -0
  104. package/src/Menu.tsx +347 -251
  105. package/src/MenuItem.stories.tsx +135 -98
  106. package/src/NavItem.mdx +1 -1
  107. package/src/PersonTrigger.mdx +20 -14
  108. package/src/PersonTrigger.tsx +23 -1
  109. package/src/Popover.mdx +130 -0
  110. package/src/Popover.stories.tsx +222 -0
  111. package/src/Popover.test.tsx +188 -0
  112. package/src/Popover.tsx +156 -0
  113. package/src/PreviewCard.mdx +98 -0
  114. package/src/PreviewCard.stories.tsx +168 -0
  115. package/src/PreviewCard.test.tsx +83 -0
  116. package/src/PreviewCard.tsx +91 -0
  117. package/src/Rail.mdx +63 -0
  118. package/src/Rail.stories.tsx +81 -0
  119. package/src/Reaction.mdx +4 -2
  120. package/src/Reaction.tsx +4 -2
  121. package/src/ReactionPicker.mdx +85 -0
  122. package/src/ReactionPicker.stories.tsx +120 -0
  123. package/src/ReactionPicker.test.tsx +118 -0
  124. package/src/ReactionPicker.tsx +88 -0
  125. package/src/SearchInput.mdx +6 -2
  126. package/src/SearchInput.tsx +7 -1
  127. package/src/Select.mdx +16 -5
  128. package/src/Select.test.tsx +157 -0
  129. package/src/Select.tsx +89 -214
  130. package/src/Sidebar.mdx +6 -2
  131. package/src/Tabs.mdx +5 -0
  132. package/src/Tabs.test.tsx +15 -0
  133. package/src/Tabs.tsx +17 -2
  134. package/src/TextInput.mdx +8 -2
  135. package/src/TextInput.tsx +10 -7
  136. package/src/Textarea.mdx +6 -2
  137. package/src/Textarea.tsx +14 -9
  138. package/src/Toolbar.mdx +104 -0
  139. package/src/Toolbar.stories.tsx +142 -0
  140. package/src/Toolbar.test.tsx +253 -0
  141. package/src/Toolbar.tsx +158 -0
  142. package/src/Tooltip.mdx +43 -5
  143. package/src/Tooltip.stories.tsx +26 -0
  144. package/src/Tooltip.test.tsx +195 -0
  145. package/src/Tooltip.tsx +150 -48
  146. package/src/cn.ts +1 -1
  147. package/src/fit.test.ts +101 -0
  148. package/src/fit.ts +19 -63
  149. package/src/index.ts +6 -3
  150. package/src/triggerDisabled.ts +13 -0
  151. package/stories/Choosing.mdx +8 -3
  152. package/tailwind-preset.js +7 -0
  153. package/src/Menu.fit.test.ts +0 -90
  154. package/src/Select.fit.test.ts +0 -101
package/src/Menu.tsx CHANGED
@@ -1,13 +1,14 @@
1
- import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
1
+ import { createContext, useContext, type ComponentPropsWithRef, type ReactElement, type ReactNode, type RefObject } from 'react'
2
2
  import { IconChevronRight } from '@tabler/icons-react'
3
- import { createPortal } from 'react-dom'
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
- * Where it goes is owned here too (2026-09-03), because the two ways a menu
27
- * goes wrong are both placement: it opens inside a stacking context or a
28
- * scroll container and something covers or clips it, or it opens near an
29
- * edge and runs off the screen. Both shipped the identity menu vanished
30
- * under a z-indexed panel header, a submenu was cut by the right edge so
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
- * Three anchorings, in order of preference:
37
- * - `anchor` (an element, usually the trigger): portalled, measured, and
38
- * placed by `fitMenu` under the anchor, flipped above when the room
39
- * below is worse, clamped inside the viewport, height-capped with its own
40
- * scrollbar. `align="right"` hangs the menu's right edge from the
41
- * anchor's. Closes on resize and on any page scroll, because both move
42
- * the anchor out from under it.
43
- * - `position` (viewport coordinates the caller computed): portalled and
44
- * clamped (`clampBox`) the caller's corner survives, but can no longer
45
- * land off screen.
46
- * - neither: in-flow under a `relative` wrapper, right-aligned. For stories
47
- * and static surfaces only inside an app this mode inherits every
48
- * ancestor's stacking context and clip, which is how the identity menu
49
- * got covered.
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
- export interface MenuProps {
52
- onClose: () => void
53
- /** The trigger — an element, or the rect a click handler already measured.
54
- * The menu portals to the body and places itself against it. */
55
- anchor?: HTMLElement | DOMRect | null
56
- /** With `anchor`: which of the menu's edges hangs from the anchor's. Default left. */
57
- align?: 'left' | 'right'
58
- /** Viewport coordinates; the menu is portalled, hung from `top`, aligned to whichever edge is given, and clamped on screen. */
59
- position?: { top: number; right: number } | { top: number; left: number }
60
- /** Close 150ms after the pointer leaves the menu the hover-flow menus
61
- * (quick-menu cards) dismiss this way. The grace period is shared with any
62
- * open MenuSub panel, so crossing into a portalled submenu never counts as
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 and
79
- * placement, and that is right for a menu opened from a trigger — but a
80
- * type-ahead popup inside a text editor cannot have them: the editor's
81
- * suggestion plugin already owns the keyboard and positions the popup, and a
82
- * second Escape handler fights it. So Peek's `@`, `/` and `[` menus each drew
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
- children: ReactNode
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('flex flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg', className)}
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 function Menu({ onClose, anchor, align = 'left', position, closeOnLeave = false, children, className }: MenuProps) {
107
- const ref = useRef<HTMLDivElement>(null)
108
- const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
109
- const hold = useCallback(() => clearTimeout(leaveTimer.current), [])
110
- const release = useCallback(() => {
111
- if (!closeOnLeave) return
112
- clearTimeout(leaveTimer.current)
113
- leaveTimer.current = setTimeout(onClose, 150)
114
- }, [closeOnLeave, onClose])
115
- useEffect(() => () => clearTimeout(leaveTimer.current), [])
116
- const portalled = Boolean(anchor || position)
117
- /** Where the menu actually goes measured against the viewport after a
118
- * hidden provisional render, so it is never covered and never cut off. */
119
- const [placed, setPlaced] = useState<{ left: number; top?: number; bottom?: number; maxHeight: number } | null>(null)
120
-
121
- useEffect(() => {
122
- const onDown = (event: MouseEvent) => {
123
- if (!ref.current?.contains(event.target as Node)) onClose()
124
- }
125
- const onKey = (event: KeyboardEvent) => {
126
- if (event.key === 'Escape') onClose()
127
- }
128
- document.addEventListener('mousedown', onDown)
129
- document.addEventListener('keydown', onKey)
130
- return () => {
131
- document.removeEventListener('mousedown', onDown)
132
- document.removeEventListener('keydown', onKey)
133
- }
134
- }, [onClose])
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
- // Callers build `position` inline every render; depending on its
137
- // coordinates rather than the object keeps the effect from re-running
138
- // (and re-placing) on every parent render.
139
- const posLeft = position && 'left' in position ? position.left : undefined
140
- const posRight = position && 'right' in position ? position.right : undefined
141
- const posTop = position?.top
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
- useLayoutEffect(() => {
144
- if (!portalled || !ref.current) return
145
- const viewport = { width: window.innerWidth, height: window.innerHeight }
146
- const menu = ref.current
147
- if (anchor) {
148
- const rect = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor
149
- const left = align === 'right' ? rect.right - menu.offsetWidth : rect.left
150
- setPlaced(
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
- /* An anchored menu is placed against its trigger's rect, and a resize or a
164
- page scroll moves the trigger out from under it — close, as Select does.
165
- A scroll INSIDE the menu is its own capped list working; leave those. */
166
- useEffect(() => {
167
- if (!anchor) return
168
- const onScroll = (event: Event) => {
169
- if (event.target instanceof Node && ref.current?.contains(event.target)) return
170
- onClose()
171
- }
172
- window.addEventListener('resize', onClose)
173
- window.addEventListener('scroll', onScroll, true)
174
- return () => {
175
- window.removeEventListener('resize', onClose)
176
- window.removeEventListener('scroll', onScroll, true)
177
- }
178
- }, [anchor, onClose])
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
- const style: CSSProperties | undefined = portalled
181
- ? placed
182
- ? { left: placed.left, top: placed.top, bottom: placed.bottom, maxHeight: placed.maxHeight }
183
- : // The provisional render: measured by the layout effect, never seen.
184
- { left: 0, top: 0, visibility: 'hidden' }
185
- : undefined
186
- const node = (
187
- <MenuHoverContext.Provider value={{ hold, release }}>
188
- <MenuPanel
189
- ref={ref}
190
- role="menu"
191
- data-interactive
192
- className={cn(
193
- 'z-50 min-w-[180px]',
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 like those were: opens at once, closes 150ms after the
215
- * pointer leaves row and panel both, so the diagonal from row to panel
216
- * survives. The panel portals to the body and is placed by `fitSubmenu`
217
- * right of the row when it fits, left when it does not, never past an edge
218
- * so it also escapes whatever container its menu happens to be in.
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
- const [open, setOpen] = useState(false)
234
- const rowRef = useRef<HTMLDivElement>(null)
235
- const panelRef = useRef<HTMLDivElement>(null)
236
- const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
237
- const [placed, setPlaced] = useState<{ left: number; top: number } | null>(null)
238
- // The panel portals out of the menu's DOM, so hovering it would read as
239
- // "left the menu" to a closeOnLeave shell — report hover upward instead.
240
- const menuHover = useContext(MenuHoverContext)
241
-
242
- const enter = () => {
243
- clearTimeout(closeTimer.current)
244
- menuHover?.hold()
245
- setOpen(true)
246
- }
247
- const leave = () => {
248
- closeTimer.current = setTimeout(() => setOpen(false), 150)
249
- menuHover?.release()
250
- }
251
- useEffect(() => () => clearTimeout(closeTimer.current), [])
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
- }, [open])
267
-
266
+ }
268
267
  return (
269
- <div ref={rowRef} onMouseEnter={enter} onMouseLeave={leave}>
270
- <MenuItem label={label} leading={leading} selected={selected} submenu />
271
- {open &&
272
- createPortal(
273
- <MenuPanel
274
- ref={panelRef}
275
- role="menu"
276
- data-interactive
277
- className={cn('fixed z-50 w-[160px]', className)}
278
- style={placed ?? { left: 0, top: 0, visibility: 'hidden' }}
279
- onMouseEnter={enter}
280
- onMouseLeave={leave}
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
- </MenuPanel>,
284
- document.body,
285
- )}
286
- </div>
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
- export function MenuItem({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
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
- <button
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
- </button>
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
- /** A section heading inside a menu: the 32px row with a SectionLabel, read
432
- * secondary a heading inside a menu labels the rows, it is not one of
433
- * them (Katerina, 2026-09-01). */
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
- return (
436
- <div className="flex flex-col">
437
- <div className={cn('flex h-8 items-center px-2', className)}>
438
- <SectionLabel className="text-text-secondary">{label}</SectionLabel>
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
- </div>
537
+ </BaseMenu.Group>
442
538
  )
443
539
  }
444
540