@estiva-app/ui 0.5.0 → 0.7.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.7.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={() => {}} />
@@ -0,0 +1,49 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ReactionStories from './Reaction.stories'
3
+
4
+ <Meta of={ReactionStories} />
5
+
6
+ # Reaction
7
+
8
+ An emoji, how many people chose it, and whether you are one of them. A
9
+ 24px pill that toggles — Chip's shape at a control's height.
10
+
11
+ <Canvas of={ReactionStories.Row} />
12
+
13
+ ## When
14
+
15
+ - Under a message, in a row of them. `pressed` is **you are one of the
16
+ reactors**: the accent's muted tint and an accent edge, so a glance
17
+ separates "two people, one of them me" from "two people".
18
+ - Any emoji at all. Another app may publish one outside your set, and a
19
+ reaction you cannot name is still one you must draw.
20
+
21
+ <Canvas of={ReactionStories.Pressed} />
22
+
23
+ ## When not
24
+
25
+ - Standing information nobody can toggle — a status, a category → **Chip**
26
+ (a Chip is not a control).
27
+ - Adding a reaction that is not there yet: that is a picker, and a strip of
28
+ icon actions is a toolbar of **IconButton**s.
29
+ - An action with a verb for a label → **Button**.
30
+
31
+ ## How
32
+
33
+ ```tsx
34
+ import { Reaction } from '@estiva-app/ui'
35
+
36
+ <Reaction emoji="👍" count={2} pressed aria-label="Makes sense, 2" onClick={toggle} />
37
+ ```
38
+
39
+ - **`aria-label` is required.** The emoji is `aria-hidden` — a glyph read
40
+ aloud is noise, and its spoken name differs per screen reader — so the
41
+ count is the only visible text and the control would otherwise be
42
+ announced as "2". Pass the meaning and the count.
43
+ - It is a real `<button>` with `aria-pressed`, so "mine" reaches assistive
44
+ tech as a toggle rather than as a colour. Every native prop passes
45
+ through; `type` defaults to `"button"`.
46
+
47
+ ## Props
48
+
49
+ <Controls of={ReactionStories.Default} />
@@ -0,0 +1,49 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { fn } from 'storybook/test'
3
+ import { Reaction } from './Reaction'
4
+
5
+ const meta = {
6
+ title: 'Components/Reaction',
7
+ component: Reaction,
8
+ args: { emoji: '👍', count: 2, 'aria-label': 'Makes sense, 2', onClick: fn() },
9
+ } satisfies Meta<typeof Reaction>
10
+
11
+ export default meta
12
+ type Story = StoryObj<typeof meta>
13
+
14
+ /** Somebody else's reaction: the inset fill and a hairline, like a neutral Chip. */
15
+ export const Default: Story = {}
16
+
17
+ /**
18
+ * Yours.
19
+ *
20
+ * The state a reaction has and a chip does not — the accent's muted tint and an
21
+ * accent edge, so a glance separates "two people, one of them me" from "two
22
+ * people". `aria-pressed` carries the same fact to assistive tech.
23
+ */
24
+ export const Pressed: Story = { args: { pressed: true } }
25
+
26
+ /** A row of them, as a message carries them. */
27
+ export const Row: Story = {
28
+ render: (args) => (
29
+ <div className="flex flex-wrap items-center gap-1">
30
+ <Reaction {...args} emoji="👍" count={3} aria-label="Makes sense, 3" pressed />
31
+ <Reaction {...args} emoji="🎉" count={1} aria-label="Congrats, 1" />
32
+ <Reaction {...args} emoji="🚀" count={12} aria-label="Let's go!, 12" />
33
+ </div>
34
+ ),
35
+ }
36
+
37
+ /** A count with two digits, so the pill grows rather than clipping. */
38
+ export const WideCount: Story = { args: { count: 128, 'aria-label': 'Makes sense, 128' } }
39
+
40
+ /** Momentarily unavailable — signing in, or a write already in flight. */
41
+ export const Disabled: Story = { args: { disabled: true } }
42
+
43
+ /**
44
+ * An emoji from outside any curated set.
45
+ *
46
+ * Another client may publish anything, and a reaction a consumer cannot name is
47
+ * still one it must draw rather than hide — so nothing here validates the glyph.
48
+ */
49
+ export const UnknownEmoji: Story = { args: { emoji: '🦆', count: 1, 'aria-label': '🦆, 1' } }