@estiva-app/ui 0.4.0 → 0.6.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 (58) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts +14 -5
  4. package/dist/AvatarGroup.d.ts.map +1 -1
  5. package/dist/ChipInput.d.ts.map +1 -1
  6. package/dist/Divider.d.ts.map +1 -1
  7. package/dist/IconButton.d.ts +4 -1
  8. package/dist/IconButton.d.ts.map +1 -1
  9. package/dist/IdentityMenu.d.ts +6 -1
  10. package/dist/IdentityMenu.d.ts.map +1 -1
  11. package/dist/Kbd.d.ts +27 -0
  12. package/dist/Kbd.d.ts.map +1 -0
  13. package/dist/Menu.d.ts +115 -11
  14. package/dist/Menu.d.ts.map +1 -1
  15. package/dist/SearchInput.d.ts +1 -1
  16. package/dist/SearchInput.d.ts.map +1 -1
  17. package/dist/Select.d.ts +2 -31
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/Tooltip.d.ts +8 -2
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/fit.d.ts +98 -0
  22. package/dist/fit.d.ts.map +1 -0
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +441 -232
  26. package/dist/index.js.map +4 -4
  27. package/package.json +1 -1
  28. package/src/AppShell.stories.tsx +2 -2
  29. package/src/AppShell.tsx +8 -1
  30. package/src/Avatar.tsx +14 -1
  31. package/src/AvatarGroup.stories.tsx +5 -0
  32. package/src/AvatarGroup.tsx +36 -13
  33. package/src/ChipInput.tsx +15 -3
  34. package/src/Divider.tsx +5 -1
  35. package/src/IconButton.tsx +5 -1
  36. package/src/IdentityMenu.tsx +14 -4
  37. package/src/Kbd.mdx +77 -0
  38. package/src/Kbd.stories.tsx +58 -0
  39. package/src/Kbd.tsx +42 -0
  40. package/src/Menu.fit.test.ts +90 -0
  41. package/src/Menu.mdx +8 -1
  42. package/src/Menu.stories.tsx +26 -3
  43. package/src/Menu.tsx +310 -34
  44. package/src/MenuItem.mdx +1 -1
  45. package/src/MenuItem.stories.tsx +5 -5
  46. package/src/SearchInput.mdx +2 -2
  47. package/src/SearchInput.stories.tsx +2 -2
  48. package/src/SearchInput.tsx +3 -6
  49. package/src/Select.fit.test.ts +12 -8
  50. package/src/Select.tsx +8 -32
  51. package/src/Tooltip.mdx +9 -0
  52. package/src/Tooltip.stories.tsx +15 -0
  53. package/src/Tooltip.tsx +19 -5
  54. package/src/TopBar.mdx +1 -1
  55. package/src/TopBar.stories.tsx +2 -2
  56. package/src/fit.ts +94 -0
  57. package/src/index.ts +3 -1
  58. package/tailwind-preset.js +9 -1
package/src/Menu.tsx CHANGED
@@ -1,7 +1,9 @@
1
- import { useEffect, useRef, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
1
+ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
2
2
  import { IconChevronRight } from '@tabler/icons-react'
3
3
  import { createPortal } from 'react-dom'
4
4
  import { cn } from './cn'
5
+ import { Kbd } from './Kbd'
6
+ import { clampBox, fitMenu, fitSubmenu } from './fit'
5
7
  import { SectionLabel } from './SectionLabel'
6
8
 
7
9
  /**
@@ -21,21 +23,100 @@ import { SectionLabel } from './SectionLabel'
21
23
  * row; and the two exits every menu has — Escape and a click outside —
22
24
  * owned by the menu, never copied into a caller.
23
25
  *
24
- * Two anchorings: `position` portals it to the body at fixed viewport
25
- * coordinates (for menus opened inside scrolling containers that clip);
26
- * without it the menu hangs below its trigger, right-aligned, and the
27
- * caller's wrapper is `relative`.
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.
35
+ *
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.
28
50
  */
29
51
  export interface MenuProps {
30
52
  onClose: () => void
31
- /** Viewport coordinates; the menu is portalled, hung from `top`, and aligned to whichever edge is given. */
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. */
32
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
33
65
  children: ReactNode
34
66
  className?: string
35
67
  }
36
68
 
37
- export function Menu({ onClose, position, children, className }: MenuProps) {
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)
73
+
74
+ /**
75
+ * The menu's surface, with none of its behaviour — an elevated box with a
76
+ * hairline border, 8px radius, 8px padding and the large shadow.
77
+ *
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.
84
+ *
85
+ * `Menu` renders this, so there is still exactly one definition of the
86
+ * surface — change it here and every menu in every app follows.
87
+ *
88
+ * Width, height and internal rhythm belong to the caller: a picker that lists
89
+ * people is not the width of one that lists verbs.
90
+ */
91
+ export interface MenuPanelProps extends Omit<ComponentPropsWithRef<'div'>, 'children'> {
92
+ children: ReactNode
93
+ }
94
+
95
+ export function MenuPanel({ children, className, ...props }: MenuPanelProps) {
96
+ return (
97
+ <div
98
+ className={cn('flex flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg', className)}
99
+ {...props}
100
+ >
101
+ {children}
102
+ </div>
103
+ )
104
+ }
105
+
106
+ export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave = false, children, className }: MenuProps) {
38
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)
39
120
 
40
121
  useEffect(() => {
41
122
  const onDown = (event: MouseEvent) => {
@@ -52,24 +133,158 @@ export function Menu({ onClose, position, children, className }: MenuProps) {
52
133
  }
53
134
  }, [onClose])
54
135
 
55
- const style: CSSProperties | undefined = position ? { top: position.top, ...('left' in position ? { left: position.left } : { right: position.right }) } : undefined
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
142
+
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])
162
+
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])
179
+
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
56
186
  const node = (
57
- <div
58
- ref={ref}
59
- role="menu"
60
- data-interactive
61
- className={cn(
62
- 'z-50 flex min-w-[180px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg',
63
- position ? 'fixed' : 'absolute right-0 top-full mt-1',
64
- className,
65
- )}
66
- style={style}
67
- onClick={(event) => event.stopPropagation()}
68
- >
69
- {children}
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>
205
+ )
206
+ return portalled ? createPortal(node, document.body) : node
207
+ }
208
+
209
+ /**
210
+ * A row that opens another menu beside it — the submenu two Peek menus
211
+ * hand-rolled before this, one of which dropped the ref its edge-flip
212
+ * measured and shipped a submenu cut off by the screen (2026-09-03).
213
+ *
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.
219
+ */
220
+ export interface MenuSubProps {
221
+ /** The trigger row's label. */
222
+ label: string
223
+ leading?: ReactNode
224
+ /** Mark the trigger row as holding a current value. */
225
+ selected?: boolean
226
+ /** The submenu's rows. */
227
+ children: ReactNode
228
+ /** On the submenu panel. */
229
+ className?: string
230
+ }
231
+
232
+ 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
+ }),
265
+ )
266
+ }, [open])
267
+
268
+ 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
+ >
282
+ {children}
283
+ </MenuPanel>,
284
+ document.body,
285
+ )}
70
286
  </div>
71
287
  )
72
- return position ? createPortal(node, document.body) : node
73
288
  }
74
289
 
75
290
  export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'children'> {
@@ -87,6 +302,23 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
87
302
  leading?: ReactNode
88
303
  /** At the right edge: a hint, a value — anything. Wins over `shortcut` and `submenu`. */
89
304
  trailing?: ReactNode
305
+ /**
306
+ * Shown at the right edge **only while this row is the one you are pointing
307
+ * at or have arrowed onto** — an `EnterHint`, typically.
308
+ *
309
+ * Pass it unconditionally. Do not do `trailing={active ? <EnterHint/> : undefined}`:
310
+ * that mounts the hint, and a mount is instant while the row's own fill is a
311
+ * 150ms fade, so the hint lands ahead of the highlight on the way in and
312
+ * vanishes ahead of it on the way out (measured 2026-09-05 — ~7 frames of a
313
+ * chip sitting on an unhighlighted row, and two rows lit at once when
314
+ * sweeping). This slot is always in the DOM and revealed by the *same*
315
+ * `:hover` / `selected` the fill uses, on the same duration and curve, so
316
+ * the two cannot come apart — and the row does not reflow when it appears.
317
+ *
318
+ * It cross-fades with `trailing`/`shortcut`/`submenu` rather than displacing
319
+ * them, and reserves the wider of the two, so nothing moves either way.
320
+ */
321
+ hint?: ReactNode
90
322
  /** A keyboard hint, drawn as the kbd chip. */
91
323
  shortcut?: string
92
324
  /** The row opens another menu: draws the chevron at the right edge. */
@@ -96,13 +328,11 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
96
328
  selected?: boolean
97
329
  }
98
330
 
99
- export function MenuItem({ label, children, size = 'default', description, leading, trailing, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
331
+ export function MenuItem({ label, children, size = 'default', description, leading, trailing, hint, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
100
332
  const edge =
101
333
  trailing ??
102
334
  (shortcut ? (
103
- <kbd className="inline-flex shrink-0 items-center justify-center rounded-sm border border-border-strong bg-bg-inset px-1 py-px text-caption text-text-secondary">
104
- {shortcut}
105
- </kbd>
335
+ <Kbd>{shortcut}</Kbd>
106
336
  ) : submenu ? (
107
337
  <IconChevronRight size={16} stroke={1.5} className="shrink-0 text-text-muted" />
108
338
  ) : null)
@@ -111,7 +341,21 @@ export function MenuItem({ label, children, size = 'default', description, leadi
111
341
  type="button"
112
342
  role="menuitem"
113
343
  className={cn(
114
- 'flex w-full cursor-pointer items-center rounded-lg text-left hover:bg-bg-hover transition-colors',
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',
115
359
  // tall: as tall as its content, never shorter than 40px (Katerina,
116
360
  // 2026-09-01) — a single-line picker row sits at 40, a row with a
117
361
  // 32px face and a role line comes out at its natural 48. One rule,
@@ -134,20 +378,52 @@ export function MenuItem({ label, children, size = 'default', description, leadi
134
378
  {description && <span className="truncate text-[12px] leading-[120%] text-text-secondary">{description}</span>}
135
379
  </span>
136
380
  )}
137
- {edge && <span className="flex shrink-0 items-center">{edge}</span>}
381
+ {(edge || hint) && (
382
+ // One grid cell holding both, right-aligned: the slot is as wide as
383
+ // the wider of the two and never changes, so revealing the hint moves
384
+ // nothing. No transition here either — the hint switches on the same
385
+ // :hover / `selected` as the fill, in the same frame.
386
+ <span className="grid shrink-0 items-center justify-items-end [&>*]:col-start-1 [&>*]:row-start-1">
387
+ {edge && (
388
+ <span className={cn('flex items-center', hint && 'group-hover:opacity-0', hint && selected && 'opacity-0')}>
389
+ {edge}
390
+ </span>
391
+ )}
392
+ {hint && (
393
+ <span className={cn('flex items-center opacity-0 group-hover:opacity-100', selected && 'opacity-100')}>
394
+ {hint}
395
+ </span>
396
+ )}
397
+ </span>
398
+ )}
138
399
  </button>
139
400
  )
140
401
  }
141
402
 
142
403
  /**
143
- * The keyboard hint a picker row shows while highlighted — "↩ Enter",
144
- * "↩ #topic" — hand-rolled in five files before this (2026-09-01).
404
+ * The keyboard hint a picker row shows while highlighted — hand-rolled in
405
+ * five files before this (2026-09-01), and drawn as its own thing until
406
+ * 2026-09-05, when it became the `Kbd` chip every other key hint uses. A
407
+ * picker row and a menu row sit in the same menu; they named the same key
408
+ * two ways.
409
+ *
410
+ * `target` is what pressing it gives you, when that is worth saying — the
411
+ * topic picker's "↩ Enter #topic". It sits after the chip, in the picker's
412
+ * own 9px voice, because it is not a key.
413
+ *
414
+ * Katerina, 2026-09-05, told the measurement and asked again: the chip keeps
415
+ * the `↩` character. It is not in Geist Mono — the browser borrows it, so it
416
+ * advances 8.63px where the font's own characters advance 6 — and that is a
417
+ * knowing trade for the glyph, not an oversight. If the width ever has to go,
418
+ * Tabler's IconCornerDownLeft draws the same shape.
145
419
  */
146
- export function EnterHint({ label = 'Enter' }: { label?: string }) {
420
+ export function EnterHint({ target }: { target?: string }) {
147
421
  return (
148
- <span className="flex shrink-0 items-center gap-2 text-text-muted">
149
- <span className="text-[12px] leading-[120%]">↩</span>
150
- <span className="text-[9px] font-medium leading-[115%] signal:font-mono signal:text-[9.5px] signal:tracking-[0.04em]">{label}</span>
422
+ <span className="flex shrink-0 items-center gap-1.5 text-text-muted">
423
+ <Kbd>↩ Enter</Kbd>
424
+ {target && (
425
+ <span className="text-[9px] font-medium leading-[115%] signal:font-mono signal:text-[9.5px] signal:tracking-[0.04em]">{target}</span>
426
+ )}
151
427
  </span>
152
428
  )
153
429
  }
package/src/MenuItem.mdx CHANGED
@@ -39,7 +39,7 @@ import { IconPencil } from '@tabler/icons-react'
39
39
  <MenuItem
40
40
  label="Rename"
41
41
  leading={<IconPencil size={16} stroke={1.5} className="text-text-secondary" />}
42
- shortcut="R"
42
+ shortcut="Ctrl+R"
43
43
  onClick={rename}
44
44
  />
45
45
  ```
@@ -40,7 +40,7 @@ export const WithAnIcon: Story = {
40
40
  }
41
41
 
42
42
  export const WithAShortcut: Story = {
43
- args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'C' },
43
+ args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: 'Ctrl+C' },
44
44
  }
45
45
 
46
46
  /** The row opens another menu. */
@@ -54,7 +54,7 @@ export const APerson: Story = {
54
54
  label: 'Ana Duarte',
55
55
  description: 'Product designer',
56
56
  leading: <Avatar name="Ana Duarte" size={32} />,
57
- trailing: <span className="text-[12px] leading-[120%] text-text-muted">↩</span>,
57
+ trailing: <EnterHint />,
58
58
  },
59
59
  }
60
60
 
@@ -74,10 +74,10 @@ export const AllVariants: Story = {
74
74
  <>
75
75
  <MenuItem label="Bare" onClick={() => {}} />
76
76
  <MenuItem label="With an icon" leading={<IconPin size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
77
- <MenuItem label="With a shortcut" shortcut="K" onClick={() => {}} />
78
- <MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="C" onClick={() => {}} />
77
+ <MenuItem label="With a shortcut" shortcut="Ctrl+K" onClick={() => {}} />
78
+ <MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
79
79
  <MenuItem label="Opens another menu" submenu onClick={() => {}} />
80
- <MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<span className="text-[12px] leading-[120%] text-text-muted">↩</span>} onClick={() => {}} />
80
+ <MenuItem label="Ana Duarte" description="Product designer" leading={<Avatar name="Ana Duarte" size={32} />} trailing={<EnterHint />} onClick={() => {}} />
81
81
  <MenuItem label="The chosen value" selected onClick={() => {}} />
82
82
  <MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
83
83
  <MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
@@ -15,7 +15,7 @@ optional keyboard hint at the right edge.
15
15
  - Filtering a list, live, above the list it filters.
16
16
  - As a **launcher affordance**: keep the input `pointer-events-none` and
17
17
  open your command surface from a click on the surround — the component
18
- is the same either way, and `shortcut` shows the way in ("K").
18
+ is the same either way, and `shortcut` shows the way in ("Ctrl+K").
19
19
 
20
20
  ## When not
21
21
 
@@ -27,7 +27,7 @@ optional keyboard hint at the right edge.
27
27
  ```tsx
28
28
  import { SearchInput } from '@estiva-app/ui'
29
29
 
30
- <SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="K" />
30
+ <SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="Ctrl+K" />
31
31
  ```
32
32
 
33
33
  - The default placeholder is "Search…" — override it with your own.
@@ -15,10 +15,10 @@ export const Default: Story = {}
15
15
 
16
16
  /** The keyboard hint at the right edge. */
17
17
  export const WithShortcut: Story = {
18
- args: { shortcut: 'K' },
18
+ args: { shortcut: 'Ctrl+K' },
19
19
  }
20
20
 
21
21
  /** The default placeholder says only "Search…" — the app names what is searched. */
22
22
  export const OwnPlaceholder: Story = {
23
- args: { placeholder: 'Search documents…', shortcut: 'K' },
23
+ args: { placeholder: 'Search documents…', shortcut: 'Ctrl+K' },
24
24
  }
@@ -1,5 +1,6 @@
1
1
  import { type InputHTMLAttributes } from 'react'
2
2
  import { cn } from './cn'
3
+ import { Kbd } from './Kbd'
3
4
 
4
5
  /**
5
6
  * Peek's SearchInput (2026-09-01), verbatim: an inset field with a hairline
@@ -15,7 +16,7 @@ import { cn } from './cn'
15
16
  * command launcher. The component is the same either way.
16
17
  */
17
18
  export interface SearchInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
18
- /** A keyboard hint drawn at the right edge, e.g. "K". */
19
+ /** A keyboard hint drawn at the right edge, e.g. "Ctrl+K". */
19
20
  shortcut?: string
20
21
  className?: string
21
22
  }
@@ -35,11 +36,7 @@ export function SearchInput({ shortcut, className, placeholder = 'Search…', ..
35
36
  placeholder={placeholder}
36
37
  {...props}
37
38
  />
38
- {shortcut && (
39
- <div className="flex items-center justify-center px-1 py-px rounded-sm bg-bg-inset border border-border-strong shrink-0 signal:bg-[rgba(255,255,255,.05)] signal:border-b-2">
40
- <span className="text-caption text-text-secondary whitespace-nowrap signal:font-mono signal:text-[10px]">{shortcut}</span>
41
- </div>
42
- )}
39
+ {shortcut && <Kbd>{shortcut}</Kbd>}
43
40
  </div>
44
41
  )
45
42
  }
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import { fitMenu } from './Select'
3
3
 
4
+ /** Select's calls carry its option-list cap (the old max-h-72); the cap is
5
+ * the caller's now, so these pin it alongside the geometry. */
6
+ const fitSelect = (args: Omit<Parameters<typeof fitMenu>[0], 'cap'>) => fitMenu({ ...args, cap: 288 })
7
+
4
8
  /**
5
9
  * The Select menu's viewport geometry (Katerina, 2026-09-01): the files-panel
6
10
  * picker was cut off at the right edge, its tail ran past the bottom of the
@@ -11,7 +15,7 @@ const viewport = { width: 1280, height: 800 }
11
15
 
12
16
  describe('fitMenu', () => {
13
17
  it('leaves a comfortable menu exactly where the trigger put it', () => {
14
- const fit = fitMenu({
18
+ const fit = fitSelect({
15
19
  anchor: { left: 100, top: 200, bottom: 232 },
16
20
  menu: { width: 240, contentHeight: 180 },
17
21
  viewport,
@@ -23,7 +27,7 @@ describe('fitMenu', () => {
23
27
  })
24
28
 
25
29
  it('clamps a menu that would run off the right edge (the files-panel cut)', () => {
26
- const fit = fitMenu({
30
+ const fit = fitSelect({
27
31
  anchor: { left: 1200, top: 200, bottom: 232 },
28
32
  menu: { width: 340, contentHeight: 180 },
29
33
  viewport,
@@ -33,7 +37,7 @@ describe('fitMenu', () => {
33
37
  })
34
38
 
35
39
  it('never pushes a menu past the LEFT edge either', () => {
36
- const fit = fitMenu({
40
+ const fit = fitSelect({
37
41
  anchor: { left: -20, top: 200, bottom: 232 },
38
42
  menu: { width: 240, contentHeight: 180 },
39
43
  viewport,
@@ -42,7 +46,7 @@ describe('fitMenu', () => {
42
46
  })
43
47
 
44
48
  it('caps height to the room below, so the last option is never off screen', () => {
45
- const fit = fitMenu({
49
+ const fit = fitSelect({
46
50
  anchor: { left: 100, top: 560, bottom: 592 },
47
51
  menu: { width: 240, contentHeight: 200 },
48
52
  viewport,
@@ -56,7 +60,7 @@ describe('fitMenu', () => {
56
60
  })
57
61
 
58
62
  it('opens upward when the room above is better (the low trigger)', () => {
59
- const fit = fitMenu({
63
+ const fit = fitSelect({
60
64
  anchor: { left: 100, top: 700, bottom: 732 },
61
65
  menu: { width: 240, contentHeight: 400 },
62
66
  viewport,
@@ -67,7 +71,7 @@ describe('fitMenu', () => {
67
71
  })
68
72
 
69
73
  it('stays below when the room below is bad but the room above is worse', () => {
70
- const fit = fitMenu({
74
+ const fit = fitSelect({
71
75
  anchor: { left: 100, top: 60, bottom: 92 },
72
76
  menu: { width: 240, contentHeight: 400 },
73
77
  viewport,
@@ -78,7 +82,7 @@ describe('fitMenu', () => {
78
82
  })
79
83
 
80
84
  it('keeps a 120px floor in a cramped corner — scrollable beats invisible', () => {
81
- const fit = fitMenu({
85
+ const fit = fitSelect({
82
86
  anchor: { left: 100, top: 740, bottom: 772 },
83
87
  menu: { width: 240, contentHeight: 400 },
84
88
  viewport: { width: 1280, height: 800 },
@@ -87,7 +91,7 @@ describe('fitMenu', () => {
87
91
  })
88
92
 
89
93
  it('caps a long list at 288 with plenty of room — the old max-h-72', () => {
90
- const fit = fitMenu({
94
+ const fit = fitSelect({
91
95
  anchor: { left: 100, top: 100, bottom: 132 },
92
96
  menu: { width: 240, contentHeight: 900 },
93
97
  viewport,
package/src/Select.tsx CHANGED
@@ -38,38 +38,11 @@ export interface SelectProps {
38
38
  className?: string
39
39
  }
40
40
 
41
- /**
42
- * Where a menu of this size goes, given its anchor and the viewport — pure,
43
- * so the geometry is testable without a browser.
44
- *
45
- * Left is clamped inside the viewport with an 8px margin. Height is capped
46
- * at 288px (the old `max-h-72`) but never taller than the space it opens
47
- * into; when the room below the anchor is smaller than both the content and
48
- * the room above, the menu opens UPWARD (anchored to the trigger's top via
49
- * `bottom`). The 120px floor keeps a menu usable even in a cramped corner —
50
- * scrollable beats invisible.
51
- */
52
- export function fitMenu({
53
- anchor,
54
- menu,
55
- viewport,
56
- }: {
57
- anchor: { left: number; top: number; bottom: number }
58
- menu: { width: number; contentHeight: number }
59
- viewport: { width: number; height: number }
60
- }): { left: number; top?: number; bottom?: number; maxHeight: number } {
61
- const MARGIN = 8
62
- const GAP = 4
63
- const CAP = 288
64
- const left = Math.max(MARGIN, Math.min(anchor.left, viewport.width - menu.width - MARGIN))
65
- const below = viewport.height - anchor.bottom - GAP - MARGIN
66
- const above = anchor.top - GAP - MARGIN
67
- const openUp = below < Math.min(menu.contentHeight, CAP) && above > below
68
- const maxHeight = Math.max(Math.min(CAP, openUp ? above : below), 120)
69
- return openUp
70
- ? { left, bottom: viewport.height - anchor.top + GAP, maxHeight }
71
- : { left, top: anchor.bottom + GAP, maxHeight }
72
- }
41
+ // The geometry lives in fit.ts now (2026-09-03) — shared with the Menu
42
+ // shell, so a cut-off surface is fixed once. Re-exported because this is
43
+ // where it grew up and its test still names it by this address.
44
+ import { fitMenu } from './fit'
45
+ export { fitMenu }
73
46
 
74
47
  export function Select({ value, onChange, options, size = 'default', ariaLabel, placeholder = 'Select…', disabled, className }: SelectProps) {
75
48
  const id = useId()
@@ -119,6 +92,9 @@ export function Select({ value, onChange, options, size = 'default', ariaLabel,
119
92
  anchor: { left: rect.left, top: rect.top, bottom: rect.bottom },
120
93
  menu: { width: menuRef.current.offsetWidth, contentHeight: menuRef.current.scrollHeight },
121
94
  viewport: { width: window.innerWidth, height: window.innerHeight },
95
+ // The option list keeps its classic height (the old max-h-72); a menu
96
+ // panel passes no cap and stands as tall as the room it opens into.
97
+ cap: 288,
122
98
  }),
123
99
  )
124
100
  }, [rect, options.length])