@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.
- package/dist/AppShell.d.ts.map +1 -1
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts +14 -5
- package/dist/AvatarGroup.d.ts.map +1 -1
- package/dist/ChipInput.d.ts.map +1 -1
- package/dist/Divider.d.ts.map +1 -1
- package/dist/IconButton.d.ts +4 -1
- package/dist/IconButton.d.ts.map +1 -1
- package/dist/IdentityMenu.d.ts +6 -1
- package/dist/IdentityMenu.d.ts.map +1 -1
- package/dist/Kbd.d.ts +27 -0
- package/dist/Kbd.d.ts.map +1 -0
- package/dist/Menu.d.ts +115 -11
- package/dist/Menu.d.ts.map +1 -1
- package/dist/SearchInput.d.ts +1 -1
- package/dist/SearchInput.d.ts.map +1 -1
- package/dist/Select.d.ts +2 -31
- package/dist/Select.d.ts.map +1 -1
- package/dist/Tooltip.d.ts +8 -2
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/fit.d.ts +98 -0
- package/dist/fit.d.ts.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +441 -232
- package/dist/index.js.map +4 -4
- package/package.json +1 -1
- package/src/AppShell.stories.tsx +2 -2
- package/src/AppShell.tsx +8 -1
- package/src/Avatar.tsx +14 -1
- package/src/AvatarGroup.stories.tsx +5 -0
- package/src/AvatarGroup.tsx +36 -13
- package/src/ChipInput.tsx +15 -3
- package/src/Divider.tsx +5 -1
- package/src/IconButton.tsx +5 -1
- package/src/IdentityMenu.tsx +14 -4
- package/src/Kbd.mdx +77 -0
- package/src/Kbd.stories.tsx +58 -0
- package/src/Kbd.tsx +42 -0
- package/src/Menu.fit.test.ts +90 -0
- package/src/Menu.mdx +8 -1
- package/src/Menu.stories.tsx +26 -3
- package/src/Menu.tsx +310 -34
- package/src/MenuItem.mdx +1 -1
- package/src/MenuItem.stories.tsx +5 -5
- package/src/SearchInput.mdx +2 -2
- package/src/SearchInput.stories.tsx +2 -2
- package/src/SearchInput.tsx +3 -6
- package/src/Select.fit.test.ts +12 -8
- package/src/Select.tsx +8 -32
- package/src/Tooltip.mdx +9 -0
- package/src/Tooltip.stories.tsx +15 -0
- package/src/Tooltip.tsx +19 -5
- package/src/TopBar.mdx +1 -1
- package/src/TopBar.stories.tsx +2 -2
- package/src/fit.ts +94 -0
- package/src/index.ts +3 -1
- 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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
|
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 —
|
|
144
|
-
*
|
|
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({
|
|
420
|
+
export function EnterHint({ target }: { target?: string }) {
|
|
147
421
|
return (
|
|
148
|
-
<span className="flex shrink-0 items-center gap-
|
|
149
|
-
<
|
|
150
|
-
|
|
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
package/src/MenuItem.stories.tsx
CHANGED
|
@@ -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: '
|
|
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: <
|
|
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="
|
|
78
|
-
<MenuItem label="Icon and shortcut" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="
|
|
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={<
|
|
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={() => {}} />
|
package/src/SearchInput.mdx
CHANGED
|
@@ -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 ("
|
|
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="
|
|
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: '
|
|
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: '
|
|
23
|
+
args: { placeholder: 'Search documents…', shortcut: 'Ctrl+K' },
|
|
24
24
|
}
|
package/src/SearchInput.tsx
CHANGED
|
@@ -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. "
|
|
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
|
}
|
package/src/Select.fit.test.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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])
|