@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.
Files changed (153) 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 +882 -763
  58. package/dist/index.js.map +4 -4
  59. package/package.json +1 -1
  60. package/src/Avatar.mdx +17 -0
  61. package/src/Avatar.name.test.tsx +99 -0
  62. package/src/Avatar.tsx +25 -3
  63. package/src/AvatarGroup.tsx +4 -1
  64. package/src/Banner.mdx +10 -2
  65. package/src/Banner.stories.tsx +29 -0
  66. package/src/Banner.test.tsx +60 -0
  67. package/src/Banner.tsx +35 -3
  68. package/src/Breadcrumb.mdx +5 -2
  69. package/src/Breadcrumb.test.tsx +44 -0
  70. package/src/Breadcrumb.tsx +10 -2
  71. package/src/Button.compose.test.tsx +119 -0
  72. package/src/Button.mdx +7 -5
  73. package/src/Button.test.tsx +2 -1
  74. package/src/Button.tsx +21 -5
  75. package/src/Checkbox.tsx +3 -0
  76. package/src/Chip.tsx +3 -2
  77. package/src/ChipInput.tsx +4 -0
  78. package/src/ConfirmDialog.mdx +20 -1
  79. package/src/ConfirmDialog.test.tsx +109 -0
  80. package/src/ConfirmDialog.tsx +6 -0
  81. package/src/DialogShell.mdx +18 -4
  82. package/src/DialogShell.stories.tsx +56 -6
  83. package/src/DialogShell.test.tsx +184 -0
  84. package/src/DialogShell.tsx +108 -39
  85. package/src/EditableText.mdx +6 -1
  86. package/src/EditableText.test.tsx +156 -0
  87. package/src/EditableText.tsx +23 -5
  88. package/src/Field.mdx +23 -3
  89. package/src/Field.stories.tsx +35 -0
  90. package/src/Field.test.tsx +117 -27
  91. package/src/Field.tsx +86 -58
  92. package/src/IconButton.mdx +6 -2
  93. package/src/IconButton.test.tsx +5 -2
  94. package/src/IconButton.tsx +12 -6
  95. package/src/IdentityMenu.mdx +29 -6
  96. package/src/IdentityMenu.stories.tsx +41 -6
  97. package/src/IdentityMenu.tsx +74 -47
  98. package/src/Kbd.stories.tsx +3 -3
  99. package/src/Menu.mdx +88 -75
  100. package/src/Menu.stories.tsx +120 -52
  101. package/src/Menu.test.tsx +315 -0
  102. package/src/Menu.tsx +341 -251
  103. package/src/MenuItem.stories.tsx +135 -98
  104. package/src/NavItem.mdx +1 -1
  105. package/src/PersonTrigger.mdx +20 -14
  106. package/src/PersonTrigger.tsx +23 -1
  107. package/src/Popover.mdx +130 -0
  108. package/src/Popover.stories.tsx +222 -0
  109. package/src/Popover.test.tsx +188 -0
  110. package/src/Popover.tsx +153 -0
  111. package/src/PreviewCard.mdx +98 -0
  112. package/src/PreviewCard.stories.tsx +168 -0
  113. package/src/PreviewCard.test.tsx +83 -0
  114. package/src/PreviewCard.tsx +91 -0
  115. package/src/Rail.mdx +63 -0
  116. package/src/Rail.stories.tsx +81 -0
  117. package/src/Reaction.mdx +4 -2
  118. package/src/Reaction.tsx +4 -2
  119. package/src/ReactionPicker.mdx +85 -0
  120. package/src/ReactionPicker.stories.tsx +120 -0
  121. package/src/ReactionPicker.test.tsx +118 -0
  122. package/src/ReactionPicker.tsx +88 -0
  123. package/src/SearchInput.mdx +6 -2
  124. package/src/SearchInput.tsx +7 -1
  125. package/src/Select.mdx +16 -5
  126. package/src/Select.test.tsx +157 -0
  127. package/src/Select.tsx +89 -214
  128. package/src/Sidebar.mdx +6 -2
  129. package/src/Tabs.mdx +5 -0
  130. package/src/Tabs.test.tsx +15 -0
  131. package/src/Tabs.tsx +17 -2
  132. package/src/TextInput.mdx +8 -2
  133. package/src/TextInput.tsx +10 -7
  134. package/src/Textarea.mdx +6 -2
  135. package/src/Textarea.tsx +14 -9
  136. package/src/Toolbar.mdx +104 -0
  137. package/src/Toolbar.stories.tsx +142 -0
  138. package/src/Toolbar.test.tsx +198 -0
  139. package/src/Toolbar.tsx +158 -0
  140. package/src/Tooltip.mdx +43 -5
  141. package/src/Tooltip.stories.tsx +26 -0
  142. package/src/Tooltip.test.tsx +195 -0
  143. package/src/Tooltip.tsx +150 -48
  144. package/src/cn.ts +1 -1
  145. package/src/fit.test.ts +101 -0
  146. package/src/fit.ts +19 -63
  147. package/src/index.ts +6 -3
  148. package/stories/Choosing.mdx +8 -3
  149. package/stories/TokensPage.tsx +6 -2
  150. package/tailwind-preset.js +12 -0
  151. package/tokens.css +8 -0
  152. package/src/Menu.fit.test.ts +0 -90
  153. package/src/Select.fit.test.ts +0 -101
package/src/Menu.tsx CHANGED
@@ -1,13 +1,13 @@
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
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
- * 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.
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
- * 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.
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
- 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)
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 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.
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
- children: ReactNode
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('flex flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg', className)}
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 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])
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
- // 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
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
- 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])
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
- /* 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])
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
- 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>
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 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.
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
- 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
- }),
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
- }, [open])
267
-
260
+ }
268
261
  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
- >
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
- </MenuPanel>,
284
- document.body,
285
- )}
286
- </div>
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
- export function MenuItem({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
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
- <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
- >
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
- </button>
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
- /** 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). */
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
- 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>
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
- </div>
531
+ </BaseMenu.Group>
442
532
  )
443
533
  }
444
534