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