@estiva-app/ui 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -59,7 +59,7 @@ const rail = (
59
59
  /** The structured frame: solid bar, sidebar, content beside it. */
60
60
  export const Solid: Story = {
61
61
  render: (args) => (
62
- <AppShell {...args} logo="Estiva" search={<SearchInput shortcut="K" className="w-[290px]" />} identity={identity} nav={sidebar}>
62
+ <AppShell {...args} logo="Estiva" search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />} identity={identity} nav={sidebar}>
63
63
  <div className="flex h-full items-center justify-center">
64
64
  <EmptyState message="Nothing here yet." />
65
65
  </div>
@@ -86,7 +86,7 @@ export const Floating: Story = {
86
86
  variant="floating"
87
87
  menu={menuButton}
88
88
  logo="Estiva"
89
- search={<SearchInput shortcut="K" className="w-[290px]" />}
89
+ search={<SearchInput shortcut="Ctrl+K" className="w-[290px]" />}
90
90
  identity={identity}
91
91
  nav={rail}
92
92
  >
package/src/Divider.tsx CHANGED
@@ -16,7 +16,11 @@ export function Divider({ orientation = 'horizontal', className }: DividerProps)
16
16
  <div
17
17
  role="separator"
18
18
  aria-orientation={orientation}
19
- className={cn('bg-border-subtle', orientation === 'horizontal' ? 'h-px mx-3' : 'w-px self-stretch shrink-0', className)}
19
+ // shrink-0 on both orientations: a 1px flex child in an overflowing
20
+ // column shrinks to nothing, and a hairline that renders 0px tall is a
21
+ // hairline nobody can see — Peek's `/` menu had been drawing two of
22
+ // them, measured 0px, since it was built (2026-09-05).
23
+ className={cn('shrink-0 bg-border-subtle', orientation === 'horizontal' ? 'h-px mx-3' : 'w-px self-stretch', className)}
20
24
  />
21
25
  )
22
26
  }
@@ -13,6 +13,9 @@ export type IconButtonVariant = 'muted' | 'outlined' | 'primary'
13
13
  export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
14
14
  variant?: IconButtonVariant
15
15
  tooltip?: string
16
+ /** A key hint drawn as the `Kbd` chip inside the tooltip — for a button
17
+ * whose only other affordance is a keyboard shortcut. */
18
+ tooltipShortcut?: string
16
19
  tooltipPlacement?: 'top' | 'bottom'
17
20
  /** The icon: 16px, stroke 1.5. */
18
21
  children: ReactNode
@@ -24,6 +27,7 @@ export function IconButton({
24
27
  children,
25
28
  disabled,
26
29
  tooltip,
30
+ tooltipShortcut,
27
31
  tooltipPlacement,
28
32
  type = 'button',
29
33
  ...props
@@ -51,7 +55,7 @@ export function IconButton({
51
55
 
52
56
  if (tooltip) {
53
57
  return (
54
- <WithTooltip label={tooltip} placement={tooltipPlacement}>
58
+ <WithTooltip label={tooltip} shortcut={tooltipShortcut} placement={tooltipPlacement}>
55
59
  {button}
56
60
  </WithTooltip>
57
61
  )
package/src/Kbd.mdx ADDED
@@ -0,0 +1,77 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as KbdStories from './Kbd.stories'
3
+
4
+ <Meta of={KbdStories} />
5
+
6
+ # Kbd
7
+
8
+ The small chip that names the key which does the same thing. A bordered
9
+ `kbd` on the inset fill, `caption` type in secondary ink, sized to its
10
+ content and never shrinking.
11
+
12
+ <Canvas of={KbdStories.Row} />
13
+
14
+ ## When
15
+
16
+ - **At the right edge of a menu row** — `MenuItem`'s `shortcut` draws one for
17
+ you; pass the string, not the element.
18
+ - **Inside a tooltip** — `Tooltip` and `WithTooltip` take a `shortcut` for the
19
+ same reason: an icon button whose only other affordance is a key.
20
+ `IconButton` forwards its own `tooltipShortcut` there.
21
+ - **At the right edge of a search field** — `SearchInput`'s `shortcut`, for
22
+ the key that puts the cursor in it.
23
+ - **In a shortcuts sheet**, where the key is the content rather than a hint.
24
+
25
+ ## Naming the key
26
+
27
+ The label is a word, never a symbol — `Cmd+K`, `Enter`, `Esc`, `Backspace`.
28
+ Two reasons, and they cost nothing to obey:
29
+
30
+ 1. A symbol is often wrong. `⌘` on a Windows machine names a key that is not
31
+ there.
32
+ 2. **The symbols are not in the font.** Geist Mono has no `⌘`, `⌫` or `↩`, so
33
+ the browser silently borrows each one from another face — measured at 10px,
34
+ every character the font owns advances exactly 6px, while `↩` comes back
35
+ 8.63px and `⌫` 12.81px. One borrowed glyph in a row of chips makes that
36
+ chip wider and its ink a different shape.
37
+
38
+ **`↑` and `↓` are the exception** — they are in Geist Mono at the same 6px, and
39
+ an arrow is what is printed on the key. Their horizontal partners are not:
40
+ Geist Mono owns `←` and `→` but draws them 10px wide, so a chip holding one
41
+ comes out wider than the chips beside it. Measure before adding a symbol to
42
+ this list; the font's coverage is not uniform.
43
+
44
+ And a trigger is the character you would actually type. Peek's resolution
45
+ block fires on `->`, so its row says `->` — there is no `→` key to press.
46
+
47
+ ## When not
48
+
49
+ - A status, count or category → **Chip**. This one means *press this*.
50
+ - A run of code inside prose → the consumer's own `code` styling; a keyboard
51
+ hint and a code span are different ideas that happen to both be monospace-ish.
52
+ - Anything clickable → **Button**. A key hint is a label, not a control.
53
+
54
+ ## How
55
+
56
+ ```tsx
57
+ import { Kbd } from '@estiva-app/ui'
58
+
59
+ <Kbd>Cmd+K</Kbd>
60
+ ```
61
+
62
+ Most callers never reach for it directly — they pass a string:
63
+
64
+ ```tsx
65
+ <MenuItem label="Heading" shortcut="#" />
66
+ <Tooltip label="Bold" shortcut="Cmd+B" />
67
+ ```
68
+
69
+ <Canvas of={KbdStories.InContext} />
70
+
71
+ It renders what it is given and formats nothing. A modifier is called Cmd on
72
+ Apple platforms and Ctrl elsewhere, and only the caller knows which it is
73
+ looking at — so decide there and pass the finished label.
74
+
75
+ ## Props
76
+
77
+ <Controls of={KbdStories.Default} />
@@ -0,0 +1,58 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Kbd } from './Kbd'
3
+ import { Menu, MenuItem, MenuSection } from './Menu'
4
+ import { Tooltip } from './Tooltip'
5
+
6
+ const meta = {
7
+ title: 'Primitives/Kbd',
8
+ component: Kbd,
9
+ args: { children: 'Cmd+K' },
10
+ argTypes: { children: { control: 'text' } },
11
+ } satisfies Meta<typeof Kbd>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ export const Default: Story = {}
17
+
18
+ /** A single trigger character, the way a command menu names its own key. */
19
+ export const TriggerCharacter: Story = { args: { children: '/' } }
20
+
21
+ /** A chord. Whoever passes it decides the platform's spelling. */
22
+ export const Chord: Story = { args: { children: 'Ctrl+Alt+1' } }
23
+
24
+ /** A named key. */
25
+ export const NamedKey: Story = { args: { children: 'Esc' } }
26
+
27
+ /** The four side by side. */
28
+ export const Row: Story = {
29
+ parameters: { controls: { disable: true } },
30
+ render: () => (
31
+ <div className="flex items-center gap-2">
32
+ {['/', 'Cmd+K', 'Ctrl+Alt+1', 'Esc'].map((k) => (
33
+ <Kbd key={k}>{k}</Kbd>
34
+ ))}
35
+ </div>
36
+ ),
37
+ }
38
+
39
+ /** Where it appears on its own: a menu row's `shortcut`, and a tooltip's. */
40
+ export const InContext: Story = {
41
+ parameters: { controls: { disable: true } },
42
+ render: () => (
43
+ <div className="flex items-start gap-6">
44
+ <Menu onClose={() => {}} className="static w-[220px]">
45
+ <MenuSection label="Format">
46
+ <MenuItem label="Heading" shortcut="#" />
47
+ <MenuItem label="Quote" shortcut=">" />
48
+ <MenuItem label="Numbered list" shortcut="1." />
49
+ </MenuSection>
50
+ </Menu>
51
+ <div className="flex flex-col gap-2">
52
+ <Tooltip label="Bold" shortcut="Cmd+B" />
53
+ <Tooltip label="Italic" shortcut="Ctrl+I" />
54
+ <Tooltip label="Comment" />
55
+ </div>
56
+ </div>
57
+ ),
58
+ }
package/src/Kbd.tsx ADDED
@@ -0,0 +1,42 @@
1
+ import type { ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+
4
+ /**
5
+ * A keyboard hint — the small chip that names the key which does the same thing.
6
+ *
7
+ * **The look is SearchInput's shortcut chip** (Katerina, 2026-09-05), which is
8
+ * the one the apps already show: an inset pill with a hairline border, and under
9
+ * Signal and Ship the keycap treatment — mono, 10px, a lighter fill and a thicker
10
+ * bottom edge, so it reads as a key rather than a label.
11
+ *
12
+ * It existed before this file did, drawn inline inside `MenuItem` and again
13
+ * inside `SearchInput`. Extracted when `Tooltip` needed a third copy.
14
+ *
15
+ * **Sizes are arbitrary values on purpose.** `text-caption` beside a
16
+ * `text-{color}` is dropped by tailwind-merge, so the chip silently inherited
17
+ * whatever size surrounded it — 10px in a composer, something else elsewhere.
18
+ * `text-[12px]` survives the merge.
19
+ *
20
+ * Content is whatever names the key — a trigger character (`/`, `@`), a chord
21
+ * (`Cmd+B`, `Ctrl+Alt+1`), or a word (`Esc`). It does not format anything: a caller
22
+ * that knows the platform passes the label it wants.
23
+ */
24
+ export interface KbdProps {
25
+ children: ReactNode
26
+ className?: string
27
+ }
28
+
29
+ export function Kbd({ children, className }: KbdProps) {
30
+ return (
31
+ <kbd
32
+ className={cn(
33
+ 'inline-flex shrink-0 items-center justify-center whitespace-nowrap rounded-sm border border-border-strong bg-bg-inset px-1 py-px font-sans text-[12px] leading-[120%] font-normal text-text-secondary',
34
+ 'signal:border-b-2 signal:pt-[2px] signal:pb-px signal:bg-[rgba(255,255,255,.05)] signal:font-mono signal:text-[10px]',
35
+ 'ship:border-b-2 ship:pt-[2px] ship:pb-px ship:bg-[rgba(255,255,255,.05)] ship:font-mono ship:text-[10px]',
36
+ className,
37
+ )}
38
+ >
39
+ {children}
40
+ </kbd>
41
+ )
42
+ }
package/src/Menu.mdx CHANGED
@@ -9,9 +9,16 @@ The shell every menu shares: an elevated container — hairline border, 8px
9
9
  radius, 8px padding, the large shadow — and the two exits every menu owes,
10
10
  **Escape and a click outside, owned here and never by the caller**. Rows are
11
11
  **MenuItem**, headings are **MenuSection**, a non-interactive line at row
12
- geometry is **MenuRow**, and **EnterHint** is the "↩ Enter" a picker row
12
+ geometry is **MenuRow**, and **EnterHint** is the `↩ Enter` chip a picker row
13
13
  shows while highlighted.
14
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.
21
+
15
22
  <Canvas of={MenuStories.Items} />
16
23
 
17
24
  ## When
@@ -27,7 +27,7 @@ export const Items: Story = {
27
27
  render: (args) => (
28
28
  <Menu {...args}>
29
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="C" onClick={() => {}} />
30
+ <MenuItem label="Copy link" leading={<IconCopy size={16} stroke={1.5} className="text-text-secondary" />} shortcut="Ctrl+C" onClick={() => {}} />
31
31
  <Divider className="my-1" />
32
32
  <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
33
33
  </Menu>
package/src/Menu.tsx CHANGED
@@ -2,6 +2,7 @@ import { createContext, useCallback, useContext, useEffect, useLayoutEffect, use
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'
5
6
  import { clampBox, fitMenu, fitSubmenu } from './fit'
6
7
  import { SectionLabel } from './SectionLabel'
7
8
 
@@ -70,6 +71,38 @@ export interface MenuProps {
70
71
  * submenu panel — the one hover region the old inline submenus had for free. */
71
72
  const MenuHoverContext = createContext<{ hold: () => void; release: () => void } | null>(null)
72
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
+
73
106
  export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave = false, children, className }: MenuProps) {
74
107
  const ref = useRef<HTMLDivElement>(null)
75
108
  const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
@@ -152,12 +185,12 @@ export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave =
152
185
  : undefined
153
186
  const node = (
154
187
  <MenuHoverContext.Provider value={{ hold, release }}>
155
- <div
188
+ <MenuPanel
156
189
  ref={ref}
157
190
  role="menu"
158
191
  data-interactive
159
192
  className={cn(
160
- 'z-50 flex min-w-[180px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg',
193
+ 'z-50 min-w-[180px]',
161
194
  portalled ? 'fixed overflow-y-auto' : 'absolute right-0 top-full mt-1',
162
195
  className,
163
196
  )}
@@ -167,7 +200,7 @@ export function Menu({ onClose, anchor, align = 'left', position, closeOnLeave =
167
200
  onMouseLeave={closeOnLeave ? release : undefined}
168
201
  >
169
202
  {children}
170
- </div>
203
+ </MenuPanel>
171
204
  </MenuHoverContext.Provider>
172
205
  )
173
206
  return portalled ? createPortal(node, document.body) : node
@@ -237,17 +270,17 @@ export function MenuSub({ label, leading, selected, children, className }: MenuS
237
270
  <MenuItem label={label} leading={leading} selected={selected} submenu />
238
271
  {open &&
239
272
  createPortal(
240
- <div
273
+ <MenuPanel
241
274
  ref={panelRef}
242
275
  role="menu"
243
276
  data-interactive
244
- className={cn('fixed z-50 flex w-[160px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg', className)}
277
+ className={cn('fixed z-50 w-[160px]', className)}
245
278
  style={placed ?? { left: 0, top: 0, visibility: 'hidden' }}
246
279
  onMouseEnter={enter}
247
280
  onMouseLeave={leave}
248
281
  >
249
282
  {children}
250
- </div>,
283
+ </MenuPanel>,
251
284
  document.body,
252
285
  )}
253
286
  </div>
@@ -269,6 +302,23 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
269
302
  leading?: ReactNode
270
303
  /** At the right edge: a hint, a value — anything. Wins over `shortcut` and `submenu`. */
271
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
272
322
  /** A keyboard hint, drawn as the kbd chip. */
273
323
  shortcut?: string
274
324
  /** The row opens another menu: draws the chevron at the right edge. */
@@ -278,13 +328,11 @@ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'ch
278
328
  selected?: boolean
279
329
  }
280
330
 
281
- 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) {
282
332
  const edge =
283
333
  trailing ??
284
334
  (shortcut ? (
285
- <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">
286
- {shortcut}
287
- </kbd>
335
+ <Kbd>{shortcut}</Kbd>
288
336
  ) : submenu ? (
289
337
  <IconChevronRight size={16} stroke={1.5} className="shrink-0 text-text-muted" />
290
338
  ) : null)
@@ -293,7 +341,21 @@ export function MenuItem({ label, children, size = 'default', description, leadi
293
341
  type="button"
294
342
  role="menuitem"
295
343
  className={cn(
296
- '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',
297
359
  // tall: as tall as its content, never shorter than 40px (Katerina,
298
360
  // 2026-09-01) — a single-line picker row sits at 40, a row with a
299
361
  // 32px face and a role line comes out at its natural 48. One rule,
@@ -316,20 +378,52 @@ export function MenuItem({ label, children, size = 'default', description, leadi
316
378
  {description && <span className="truncate text-[12px] leading-[120%] text-text-secondary">{description}</span>}
317
379
  </span>
318
380
  )}
319
- {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
+ )}
320
399
  </button>
321
400
  )
322
401
  }
323
402
 
324
403
  /**
325
- * The keyboard hint a picker row shows while highlighted — "↩ Enter",
326
- * "↩ #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.
327
419
  */
328
- export function EnterHint({ label = 'Enter' }: { label?: string }) {
420
+ export function EnterHint({ target }: { target?: string }) {
329
421
  return (
330
- <span className="flex shrink-0 items-center gap-2 text-text-muted">
331
- <span className="text-[12px] leading-[120%]">↩</span>
332
- <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
+ )}
333
427
  </span>
334
428
  )
335
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
  }
package/src/Tooltip.mdx CHANGED
@@ -19,6 +19,11 @@ viewport, kept 8px inside its edges.
19
19
  - The word for an icon-only control — alongside its `aria-label`, never
20
20
  instead of it (IconButton wires this for you via its `tooltip` prop).
21
21
  - The full text behind a truncation.
22
+ - **The key that does the same thing** — pass `shortcut` and it is drawn as
23
+ the `Kbd` chip after the label. For an icon-only control whose only other
24
+ affordance is a keyboard shortcut, the tooltip is the only place to say so.
25
+
26
+ <Canvas of={TooltipStories.ShortcutComparison} />
22
27
 
23
28
  <Canvas of={TooltipStories.OnADisabledControl} />
24
29
 
@@ -45,6 +50,10 @@ import { WithTooltip } from '@estiva-app/ui'
45
50
  `wrapperClassName="min-w-0 shrink"`.
46
51
  - It shows on **hover only** — there is no focus or touch trigger. Don't
47
52
  put anything behind it that a keyboard user must reach.
53
+ - `shortcut` renders, it does not format. A modifier is called Cmd on Apple
54
+ platforms and Ctrl elsewhere, and only the caller knows which it is
55
+ looking at — so pass the finished label. `IconButton` forwards its own
56
+ `tooltipShortcut` here.
48
57
 
49
58
  ## Props
50
59
 
@@ -14,6 +14,21 @@ type Story = StoryObj<typeof meta>
14
14
  /** The static tooltip surface. */
15
15
  export const Default: Story = {}
16
16
 
17
+ /** With a key hint — drawn as the `Kbd` chip after the label. */
18
+ export const WithShortcut: Story = { args: { label: 'Bold', shortcut: 'Cmd+B' } }
19
+
20
+ /** With and without, so the difference is one glance. */
21
+ export const ShortcutComparison: Story = {
22
+ parameters: { controls: { disable: true } },
23
+ render: () => (
24
+ <div className="flex flex-col items-start gap-2">
25
+ <Tooltip label="Comment" />
26
+ <Tooltip label="Bold" shortcut="Cmd+B" />
27
+ <Tooltip label="Heading" shortcut="Ctrl+Alt+1" />
28
+ </div>
29
+ ),
30
+ }
31
+
17
32
  /** Hover the button — WithTooltip portals the tooltip above the trigger. */
18
33
  export const OnHoverTop: Story = {
19
34
  parameters: { controls: { disable: true } },