@estiva-app/ui 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/AppShell.d.ts.map +1 -1
  2. package/dist/Avatar.d.ts.map +1 -1
  3. package/dist/AvatarGroup.d.ts +14 -5
  4. package/dist/AvatarGroup.d.ts.map +1 -1
  5. package/dist/ChipInput.d.ts.map +1 -1
  6. package/dist/Divider.d.ts.map +1 -1
  7. package/dist/IconButton.d.ts +4 -1
  8. package/dist/IconButton.d.ts.map +1 -1
  9. package/dist/IdentityMenu.d.ts +6 -1
  10. package/dist/IdentityMenu.d.ts.map +1 -1
  11. package/dist/Kbd.d.ts +27 -0
  12. package/dist/Kbd.d.ts.map +1 -0
  13. package/dist/Menu.d.ts +115 -11
  14. package/dist/Menu.d.ts.map +1 -1
  15. package/dist/SearchInput.d.ts +1 -1
  16. package/dist/SearchInput.d.ts.map +1 -1
  17. package/dist/Select.d.ts +2 -31
  18. package/dist/Select.d.ts.map +1 -1
  19. package/dist/Tooltip.d.ts +8 -2
  20. package/dist/Tooltip.d.ts.map +1 -1
  21. package/dist/fit.d.ts +98 -0
  22. package/dist/fit.d.ts.map +1 -0
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +441 -232
  26. package/dist/index.js.map +4 -4
  27. package/package.json +1 -1
  28. package/src/AppShell.stories.tsx +2 -2
  29. package/src/AppShell.tsx +8 -1
  30. package/src/Avatar.tsx +14 -1
  31. package/src/AvatarGroup.stories.tsx +5 -0
  32. package/src/AvatarGroup.tsx +36 -13
  33. package/src/ChipInput.tsx +15 -3
  34. package/src/Divider.tsx +5 -1
  35. package/src/IconButton.tsx +5 -1
  36. package/src/IdentityMenu.tsx +14 -4
  37. package/src/Kbd.mdx +77 -0
  38. package/src/Kbd.stories.tsx +58 -0
  39. package/src/Kbd.tsx +42 -0
  40. package/src/Menu.fit.test.ts +90 -0
  41. package/src/Menu.mdx +8 -1
  42. package/src/Menu.stories.tsx +26 -3
  43. package/src/Menu.tsx +310 -34
  44. package/src/MenuItem.mdx +1 -1
  45. package/src/MenuItem.stories.tsx +5 -5
  46. package/src/SearchInput.mdx +2 -2
  47. package/src/SearchInput.stories.tsx +2 -2
  48. package/src/SearchInput.tsx +3 -6
  49. package/src/Select.fit.test.ts +12 -8
  50. package/src/Select.tsx +8 -32
  51. package/src/Tooltip.mdx +9 -0
  52. package/src/Tooltip.stories.tsx +15 -0
  53. package/src/Tooltip.tsx +19 -5
  54. package/src/TopBar.mdx +1 -1
  55. package/src/TopBar.stories.tsx +2 -2
  56. package/src/fit.ts +94 -0
  57. package/src/index.ts +3 -1
  58. package/tailwind-preset.js +9 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estiva-app/ui",
3
- "version": "0.4.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/AppShell.tsx CHANGED
@@ -63,7 +63,14 @@ export function AppShell({ variant = 'solid', menu, logo, search, identity, bann
63
63
  }
64
64
 
65
65
  return (
66
- <div className="flex h-full min-h-0 flex-col bg-bg-base text-text-primary">
66
+ /* `relative overflow-hidden` is the seal the floating manner already has,
67
+ and it takes both halves: an absolutely positioned descendant with no
68
+ positioned ancestor belongs to the *viewport*, so a scroll container
69
+ never clips it and the document itself gains its position as scroll
70
+ range — the whole page scrolls, navigation and top bar included.
71
+ `relative` claims such strays for the shell; `overflow-hidden` clips
72
+ them at its edge. Either alone seals nothing. */
73
+ <div className="relative flex h-full min-h-0 flex-col overflow-hidden bg-bg-base text-text-primary">
67
74
  {bar}
68
75
  <div className="flex min-h-0 flex-1">
69
76
  {nav}
package/src/Avatar.tsx CHANGED
@@ -65,7 +65,20 @@ export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProp
65
65
  <img src={picture} alt={alt || name || ''} className="w-full h-full object-cover" onError={() => setBroken(true)} />
66
66
  ) : label ? (
67
67
  <div
68
- className="w-full h-full flex items-center justify-center font-semibold"
68
+ /*
69
+ `leading-none` is load-bearing (2026-09-03). Centring a flex child
70
+ centres its LINE BOX, and a line box reserves room under the
71
+ baseline for descenders — which capitals never use — so initials
72
+ floated above the middle of every tile. It also inherited whatever
73
+ line-height surrounded it, so the same face sat differently in a
74
+ members pill and in a replies row. Measured over six letter pairs
75
+ at 18/24/36px: mean 0.64px high before, 0.06px after.
76
+
77
+ Per-letter variation stays (a "Y" carries its mass up top, a "ZB"
78
+ more than an "AJ") — that is the letterform, not the box, and it
79
+ is not something a rule here can flatten.
80
+ */
81
+ className="w-full h-full flex items-center justify-center font-semibold leading-none"
69
82
  style={{
70
83
  color: '#08121c',
71
84
  fontSize: Math.round(size * 0.36),
@@ -33,6 +33,11 @@ export const OverflowShowsThree: Story = {
33
33
  },
34
34
  }
35
35
 
36
+ /** The smaller face, for a dense row — a reply line's authors, say. */
37
+ export const Small: Story = {
38
+ args: { size: 18 },
39
+ }
40
+
36
41
  /**
37
42
  * Inside a members pill with the total count — the shape a conversation
38
43
  * header draws over the stack. The pill is the caller's; this is the group
@@ -8,10 +8,13 @@ import { Avatar } from './Avatar'
8
8
  * wrapper resolves pictures itself; a consumer without such a wrapper could
9
9
  * never have shown one.
10
10
  *
11
- * The ring sits on the Avatar itself: the old extra wrapper was a 24px
12
- * window minus a 2px border with a fixed 24px Avatar inside, so
13
- * overflow-hidden clipped 4px of face and the right edge of the initials
14
- * with it, which is how "CO" rendered as "C(". One box, whole letters.
11
+ * The ring is drawn OUTSIDE the face, as a shadow (2026-09-03). It was a
12
+ * `border` before, which box-sizing takes out of the inside: a 24px avatar
13
+ * showed 20px of face, and at 18px only 14px — noticeably smaller than the
14
+ * number asked for. A shadow costs the face nothing, so `size` means the
15
+ * size. It replaced an even older wrapper whose `overflow-hidden` clipped
16
+ * 4px of face and the right edge of the initials with it ("CO" as "C(") —
17
+ * hence the rule this file keeps: nothing that clips ever wraps the face.
15
18
  */
16
19
  export interface AvatarGroupMember {
17
20
  name: string
@@ -21,21 +24,41 @@ export interface AvatarGroupMember {
21
24
 
22
25
  export interface AvatarGroupProps {
23
26
  members: AvatarGroupMember[]
27
+ /**
28
+ * Face size in pixels; the overlap follows it. 24 is the members pill,
29
+ * 18 the replies row (Katerina, 2026-09-03) — that row drew its own stack
30
+ * for two months for want of this one number, and drifted while it did.
31
+ */
32
+ size?: number
24
33
  }
25
34
 
26
- export function AvatarGroup({ members }: AvatarGroupProps) {
35
+ export function AvatarGroup({ members, size = 24 }: AvatarGroupProps) {
27
36
  const visible = members.slice(0, 3)
37
+ /*
38
+ A third of the face, so a stack reads the same at any size — which is
39
+ exactly the ratio both hand-drawn stacks had arrived at independently
40
+ (8px on 24, 6px on 18). The container gives the last face its overlap
41
+ back as padding, so the group measures its true width.
42
+ */
43
+ const overlap = Math.round(size / 3)
44
+ // The ring scales with the face — 2px at 24, 1.5px at 18, which is what both
45
+ // hand-drawn stacks used before this component carried either of them.
46
+ const ring = size / 12
28
47
  return (
29
- <div className="flex items-center pr-2">
48
+ <div className="flex items-center" style={{ paddingRight: overlap }}>
30
49
  {visible.map((member, i) => (
31
- <Avatar
50
+ /*
51
+ The wrapper carries the overlap and the ring; the face carries
52
+ neither. It must never clip — see the note at the top of the file —
53
+ so it has the avatar's own radius and no overflow of its own.
54
+ */
55
+ <span
32
56
  key={i}
33
- size={24}
34
- name={member.name}
35
- src={member.picture}
36
- alt={member.name}
37
- className="-mr-2 relative border-2 border-bg-surface"
38
- />
57
+ className="relative flex rounded-sm"
58
+ style={{ marginRight: -overlap, boxShadow: `0 0 0 ${ring}px var(--bg-surface)` }}
59
+ >
60
+ <Avatar size={size} name={member.name} src={member.picture} alt={member.name} />
61
+ </span>
39
62
  ))}
40
63
  </div>
41
64
  )
package/src/ChipInput.tsx CHANGED
@@ -2,6 +2,7 @@ import { useState, useRef, useMemo, useEffect, useLayoutEffect, type KeyboardEve
2
2
  import { createPortal } from 'react-dom'
3
3
  import { IconX } from '@tabler/icons-react'
4
4
  import { cn } from './cn'
5
+ import { fitMenu } from './fit'
5
6
  import { MenuItem } from './Menu'
6
7
 
7
8
  /**
@@ -216,10 +217,21 @@ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
216
217
 
217
218
  {showDropdown && anchorRect && createPortal(
218
219
  <div
219
- className="fixed z-[60] max-h-[240px] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg"
220
+ className="fixed z-[60] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg"
221
+ /*
222
+ Placed by fitMenu (2026-09-03), not hung blindly below: an input
223
+ low on the screen flips its list upward instead of running the
224
+ tail past the bottom edge. The rows are a fixed 48px, so the
225
+ content height is arithmetic and needs no second render pass;
226
+ the 240px cap is the old max-h-[240px].
227
+ */
220
228
  style={{
221
- top: anchorRect.bottom + 4,
222
- left: anchorRect.left,
229
+ ...fitMenu({
230
+ anchor: { left: anchorRect.left, top: anchorRect.top, bottom: anchorRect.bottom },
231
+ menu: { width: anchorRect.width, contentHeight: matches.length * 48 },
232
+ viewport: { width: window.innerWidth, height: window.innerHeight },
233
+ cap: 240,
234
+ }),
223
235
  width: anchorRect.width,
224
236
  }}
225
237
  >
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
  )
@@ -1,4 +1,4 @@
1
- import { useState, type ReactNode } from 'react'
1
+ import { useRef, useState, type ReactNode } from 'react'
2
2
  import { cn } from './cn'
3
3
  import { Divider } from './Divider'
4
4
  import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
@@ -55,6 +55,11 @@ export interface IdentityMenuProps {
55
55
 
56
56
  export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact' | 'className'> {
57
57
  onClose: () => void
58
+ /** The trigger to hang from — the panel portals to the body and fits the
59
+ * viewport, so no header, sidebar or scroll container can cover it (the
60
+ * z-10 floating top bar trapped the old in-flow panel under a z-20 panel
61
+ * header, 2026-09-03). Absent, the panel stands in flow — the stories. */
62
+ anchor?: HTMLElement | null
58
63
  /** On the Menu surface — the stories pass `static` to stand it in flow. */
59
64
  className?: string
60
65
  }
@@ -62,14 +67,14 @@ export interface IdentityPanelProps extends Omit<IdentityMenuProps, 'compact' |
62
67
  /** The menu alone — what `IdentityMenu` opens. Exported so the stories show
63
68
  * the designed artifact rather than a closed trigger, and for any surface
64
69
  * that wants the panel without the trigger. */
65
- export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, className, children }: IdentityPanelProps) {
70
+ export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, onClose, anchor, className, children }: IdentityPanelProps) {
66
71
  const act = (action: () => void) => () => {
67
72
  onClose()
68
73
  action()
69
74
  }
70
75
  const appRows = typeof children === 'function' ? children(onClose) : children
71
76
  return (
72
- <Menu onClose={onClose} className={cn('w-72', className)}>
77
+ <Menu onClose={onClose} anchor={anchor} align="right" className={cn('w-72', className)}>
73
78
  <MenuSection label={signedIn ? 'Signed in as' : 'Acting as'}>
74
79
  <MenuRow>
75
80
  <Person name={me.name} picture={me.picture} fallback="Anonymous" size={28} className="text-body-2-strong" />
@@ -117,9 +122,13 @@ export function IdentityPanel({ me, signedIn, relayUrl, idBase, onCopyKey, onSig
117
122
 
118
123
  export function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSignOut, compact = false, className, children }: IdentityMenuProps) {
119
124
  const [open, setOpen] = useState(false)
125
+ /* The wrapper is the anchor: the panel hangs its right edge from this
126
+ div's, exactly where the old in-flow `absolute right-0` put it — but
127
+ portalled, so nothing z-indexed in the app can cover it. */
128
+ const anchorRef = useRef<HTMLDivElement>(null)
120
129
 
121
130
  return (
122
- <div className={cn('relative', className)}>
131
+ <div ref={anchorRef} className={cn('relative', className)}>
123
132
  <PersonTrigger
124
133
  name={me.name}
125
134
  picture={me.picture}
@@ -146,6 +155,7 @@ export function IdentityMenu({ me, signedIn, relayUrl, idBase, onCopyKey, onSign
146
155
  onCopyKey={onCopyKey}
147
156
  onSignOut={onSignOut}
148
157
  onClose={() => setOpen(false)}
158
+ anchor={anchorRef.current}
149
159
  >
150
160
  {children}
151
161
  </IdentityPanel>
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
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { clampBox, fitMenu, fitSubmenu } from './fit'
3
+
4
+ /**
5
+ * The Menu shell's viewport geometry (Katerina, 2026-09-03): the identity
6
+ * menu vanished under a z-indexed panel header, and a reply menu's highlight
7
+ * submenu was cut off by the right screen edge — its hand-rolled flip read a
8
+ * ref that was never attached, so it measured nothing. The portal fixes the
9
+ * covering; these pin the pure half of the fitting.
10
+ */
11
+ const viewport = { width: 1280, height: 800 }
12
+
13
+ describe('fitMenu without a cap (the Menu shell)', () => {
14
+ it('lets a tall panel stand at its full height when the room is there — no scrollbar', () => {
15
+ // The identity panel: ~450px tall on a 900px screen. With Select's 288
16
+ // cap it grew a scrollbar at full height; without one it just stands.
17
+ const fit = fitMenu({
18
+ anchor: { left: 1100, top: 20, bottom: 56 },
19
+ menu: { width: 288, contentHeight: 450 },
20
+ viewport: { width: 1440, height: 900 },
21
+ })
22
+ expect(fit.maxHeight).toBeGreaterThanOrEqual(450)
23
+ })
24
+
25
+ it('still caps to the room when the screen truly runs out', () => {
26
+ const fit = fitMenu({
27
+ anchor: { left: 100, top: 20, bottom: 56 },
28
+ menu: { width: 288, contentHeight: 1200 },
29
+ viewport: { width: 1440, height: 900 },
30
+ })
31
+ expect(fit.maxHeight).toBe(900 - 56 - 4 - 8)
32
+ })
33
+ })
34
+
35
+ describe('clampBox', () => {
36
+ it('leaves a well-placed box exactly where the caller put it', () => {
37
+ const fit = clampBox({ box: { left: 100, top: 200, width: 300, height: 400 }, viewport })
38
+ expect(fit).toEqual({ left: 100, top: 200, maxHeight: 400 })
39
+ })
40
+
41
+ it('slides a box back inside the right edge, margin kept', () => {
42
+ const fit = clampBox({ box: { left: 1100, top: 200, width: 300, height: 400 }, viewport })
43
+ expect(fit.left).toBe(1280 - 300 - 8)
44
+ })
45
+
46
+ it('never pushes a box past the left edge either', () => {
47
+ const fit = clampBox({ box: { left: -40, top: 200, width: 300, height: 400 }, viewport })
48
+ expect(fit.left).toBe(8)
49
+ })
50
+
51
+ it('slides a box up so its tail stays on screen', () => {
52
+ const fit = clampBox({ box: { left: 100, top: 700, width: 300, height: 400 }, viewport })
53
+ expect(fit.top).toBe(800 - 400 - 8)
54
+ })
55
+
56
+ it('caps a box taller than the viewport and pins it to the top margin', () => {
57
+ const fit = clampBox({ box: { left: 100, top: 100, width: 300, height: 900 }, viewport })
58
+ expect(fit.maxHeight).toBe(800 - 16)
59
+ expect(fit.top).toBe(8)
60
+ })
61
+
62
+ it('keeps the 120px floor — scrollable beats invisible', () => {
63
+ const fit = clampBox({ box: { left: 100, top: 200, width: 300, height: 40 }, viewport })
64
+ expect(fit.maxHeight).toBeGreaterThanOrEqual(120)
65
+ })
66
+ })
67
+
68
+ describe('fitSubmenu', () => {
69
+ const panel = { width: 160, height: 200 }
70
+
71
+ it('opens to the right of a row with room', () => {
72
+ const fit = fitSubmenu({ row: { left: 500, right: 740, top: 300 }, panel, viewport })
73
+ expect(fit).toEqual({ left: 744, top: 300 })
74
+ })
75
+
76
+ it('flips to the left at the right screen edge (the highlight submenu cut)', () => {
77
+ const fit = fitSubmenu({ row: { left: 1000, right: 1240, top: 300 }, panel, viewport })
78
+ expect(fit.left).toBe(1000 - 4 - 160)
79
+ })
80
+
81
+ it('never lands past the left edge even when flipped from a narrow spot', () => {
82
+ const fit = fitSubmenu({ row: { left: 60, right: 1240, top: 300 }, panel, viewport })
83
+ expect(fit.left).toBe(8)
84
+ })
85
+
86
+ it('slides up when the tail would run past the bottom', () => {
87
+ const fit = fitSubmenu({ row: { left: 500, right: 740, top: 700 }, panel, viewport })
88
+ expect(fit.top).toBe(800 - 200 - 8)
89
+ })
90
+ })
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
@@ -1,7 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import { IconCopy, IconPencil, IconTrash } from '@tabler/icons-react'
2
+ import { IconCopy, IconHighlight, IconPencil, IconTrash } from '@tabler/icons-react'
3
3
  import { Divider } from './Divider'
4
- import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
4
+ import { Menu, MenuItem, MenuRow, MenuSection, MenuSub } from './Menu'
5
5
  import { SectionLabel } from './SectionLabel'
6
6
 
7
7
  /**
@@ -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>
@@ -51,6 +51,29 @@ export const Sections: Story = {
51
51
  ),
52
52
  }
53
53
 
54
+ /**
55
+ * A row that opens another menu beside it — hover it. The panel portals to
56
+ * the body and fits the viewport: right of the row with room, flipped left
57
+ * at the screen edge, never cut off. Live rather than pinned, because the
58
+ * placement IS the designed behaviour.
59
+ */
60
+ export const WithASubmenu: Story = {
61
+ render: (args) => (
62
+ <Menu {...args}>
63
+ <MenuItem label="Rename" leading={<IconPencil size={16} stroke={1.5} className="text-text-secondary" />} onClick={() => {}} />
64
+ <MenuSub label="Mark as Highlight" leading={<IconHighlight size={16} stroke={1.5} className="text-text-secondary" />}>
65
+ <MenuItem label="Insight" onClick={() => {}} />
66
+ <MenuItem label="Concern" onClick={() => {}} />
67
+ <MenuItem label="Conclusion" onClick={() => {}} />
68
+ <MenuItem label="Question" onClick={() => {}} />
69
+ <MenuItem label="Summary" onClick={() => {}} />
70
+ </MenuSub>
71
+ <Divider className="my-1" />
72
+ <MenuItem label="Delete" destructive onClick={() => {}} />
73
+ </Menu>
74
+ ),
75
+ }
76
+
54
77
  /** A non-interactive row at the item's geometry — identity lines, hints. */
55
78
  export const WithARow: Story = {
56
79
  render: (args) => (