@estiva-app/ui 0.1.0 → 0.2.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 (157) hide show
  1. package/README.md +12 -9
  2. package/base.css +43 -0
  3. package/dist/AppShell.d.ts +39 -0
  4. package/dist/AppShell.d.ts.map +1 -0
  5. package/dist/Avatar.d.ts +7 -2
  6. package/dist/Avatar.d.ts.map +1 -1
  7. package/dist/AvatarGroup.d.ts +23 -0
  8. package/dist/AvatarGroup.d.ts.map +1 -0
  9. package/dist/Banner.d.ts +19 -0
  10. package/dist/Banner.d.ts.map +1 -0
  11. package/dist/Breadcrumb.d.ts +28 -0
  12. package/dist/Breadcrumb.d.ts.map +1 -0
  13. package/dist/Checkbox.d.ts +18 -0
  14. package/dist/Checkbox.d.ts.map +1 -0
  15. package/dist/ChipInput.d.ts +57 -0
  16. package/dist/ChipInput.d.ts.map +1 -0
  17. package/dist/ConfirmDialog.d.ts +22 -0
  18. package/dist/ConfirmDialog.d.ts.map +1 -0
  19. package/dist/DialogShell.d.ts +9 -2
  20. package/dist/DialogShell.d.ts.map +1 -1
  21. package/dist/EditableText.d.ts +36 -0
  22. package/dist/EditableText.d.ts.map +1 -0
  23. package/dist/IdentityMenu.d.ts +58 -0
  24. package/dist/IdentityMenu.d.ts.map +1 -0
  25. package/dist/Menu.d.ts +82 -0
  26. package/dist/Menu.d.ts.map +1 -0
  27. package/dist/NavItem.d.ts +26 -0
  28. package/dist/NavItem.d.ts.map +1 -0
  29. package/dist/Person.d.ts +26 -0
  30. package/dist/Person.d.ts.map +1 -0
  31. package/dist/PersonTrigger.d.ts +23 -0
  32. package/dist/PersonTrigger.d.ts.map +1 -0
  33. package/dist/Property.d.ts +24 -0
  34. package/dist/Property.d.ts.map +1 -0
  35. package/dist/Rail.d.ts +18 -0
  36. package/dist/Rail.d.ts.map +1 -0
  37. package/dist/RailItem.d.ts +23 -0
  38. package/dist/RailItem.d.ts.map +1 -0
  39. package/dist/SearchInput.d.ts +21 -0
  40. package/dist/SearchInput.d.ts.map +1 -0
  41. package/dist/SectionHeader.d.ts +34 -0
  42. package/dist/SectionHeader.d.ts.map +1 -0
  43. package/dist/SectionLabel.d.ts +19 -0
  44. package/dist/SectionLabel.d.ts.map +1 -0
  45. package/dist/Select.d.ts +42 -5
  46. package/dist/Select.d.ts.map +1 -1
  47. package/dist/Sidebar.d.ts +18 -0
  48. package/dist/Sidebar.d.ts.map +1 -0
  49. package/dist/Tabs.d.ts +34 -0
  50. package/dist/Tabs.d.ts.map +1 -0
  51. package/dist/Toast.d.ts +48 -0
  52. package/dist/Toast.d.ts.map +1 -0
  53. package/dist/Tooltip.d.ts +3 -1
  54. package/dist/Tooltip.d.ts.map +1 -1
  55. package/dist/TopBar.d.ts +33 -0
  56. package/dist/TopBar.d.ts.map +1 -0
  57. package/dist/cn.d.ts +14 -4
  58. package/dist/cn.d.ts.map +1 -1
  59. package/dist/index.d.ts +23 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1254 -106
  62. package/dist/index.js.map +4 -4
  63. package/package.json +5 -2
  64. package/src/AppShell.mdx +66 -0
  65. package/src/AppShell.stories.tsx +117 -0
  66. package/src/AppShell.tsx +77 -0
  67. package/src/Avatar.initials.test.ts +21 -0
  68. package/src/Avatar.mdx +49 -0
  69. package/src/Avatar.tsx +12 -9
  70. package/src/AvatarGroup.mdx +39 -0
  71. package/src/AvatarGroup.stories.tsx +56 -0
  72. package/src/AvatarGroup.tsx +42 -0
  73. package/src/Banner.mdx +45 -0
  74. package/src/Banner.stories.tsx +35 -0
  75. package/src/Banner.tsx +35 -0
  76. package/src/Breadcrumb.mdx +52 -0
  77. package/src/Breadcrumb.stories.tsx +42 -0
  78. package/src/Breadcrumb.tsx +106 -0
  79. package/src/Button.mdx +57 -0
  80. package/src/Checkbox.mdx +44 -0
  81. package/src/Checkbox.stories.tsx +49 -0
  82. package/src/Checkbox.tsx +46 -0
  83. package/src/Chip.mdx +43 -0
  84. package/src/ChipInput.mdx +68 -0
  85. package/src/ChipInput.stories.tsx +99 -0
  86. package/src/ChipInput.tsx +247 -0
  87. package/src/ConfirmDialog.mdx +51 -0
  88. package/src/ConfirmDialog.stories.tsx +63 -0
  89. package/src/ConfirmDialog.tsx +55 -0
  90. package/src/DialogShell.mdx +68 -0
  91. package/src/DialogShell.tsx +12 -5
  92. package/src/Divider.mdx +41 -0
  93. package/src/EditableText.mdx +59 -0
  94. package/src/EditableText.stories.tsx +63 -0
  95. package/src/EditableText.tsx +150 -0
  96. package/src/EmptyState.mdx +41 -0
  97. package/src/Field.mdx +39 -0
  98. package/src/IconButton.mdx +47 -0
  99. package/src/IdentityMenu.mdx +62 -0
  100. package/src/IdentityMenu.stories.tsx +52 -0
  101. package/src/IdentityMenu.tsx +159 -0
  102. package/src/Menu.mdx +111 -0
  103. package/src/Menu.stories.tsx +66 -0
  104. package/src/Menu.tsx +172 -0
  105. package/src/MenuItem.mdx +55 -0
  106. package/src/MenuItem.stories.tsx +98 -0
  107. package/src/NavItem.mdx +53 -0
  108. package/src/NavItem.stories.tsx +36 -0
  109. package/src/NavItem.tsx +53 -0
  110. package/src/Person.mdx +44 -0
  111. package/src/Person.stories.tsx +43 -0
  112. package/src/Person.tsx +39 -0
  113. package/src/PersonTrigger.mdx +51 -0
  114. package/src/PersonTrigger.stories.tsx +30 -0
  115. package/src/PersonTrigger.tsx +62 -0
  116. package/src/Property.mdx +51 -0
  117. package/src/Property.stories.tsx +91 -0
  118. package/src/Property.tsx +44 -0
  119. package/src/Rail.tsx +26 -0
  120. package/src/RailItem.mdx +50 -0
  121. package/src/RailItem.stories.tsx +37 -0
  122. package/src/RailItem.tsx +53 -0
  123. package/src/SearchInput.mdx +39 -0
  124. package/src/SearchInput.stories.tsx +24 -0
  125. package/src/SearchInput.tsx +45 -0
  126. package/src/SectionHeader.mdx +52 -0
  127. package/src/SectionHeader.stories.tsx +66 -0
  128. package/src/SectionHeader.tsx +85 -0
  129. package/src/SectionLabel.mdx +42 -0
  130. package/src/SectionLabel.stories.tsx +31 -0
  131. package/src/SectionLabel.tsx +28 -0
  132. package/src/Select.fit.test.ts +97 -0
  133. package/src/Select.mdx +71 -0
  134. package/src/Select.stories.tsx +95 -0
  135. package/src/Select.tsx +123 -15
  136. package/src/Sidebar.mdx +56 -0
  137. package/src/Sidebar.stories.tsx +50 -0
  138. package/src/Sidebar.tsx +32 -0
  139. package/src/Skeleton.mdx +39 -0
  140. package/src/Tabs.mdx +53 -0
  141. package/src/Tabs.stories.tsx +91 -0
  142. package/src/Tabs.tsx +73 -0
  143. package/src/TextInput.mdx +39 -0
  144. package/src/Textarea.mdx +36 -0
  145. package/src/Toast.mdx +54 -0
  146. package/src/Toast.stories.tsx +87 -0
  147. package/src/Toast.tsx +169 -0
  148. package/src/Tooltip.mdx +51 -0
  149. package/src/Tooltip.tsx +4 -2
  150. package/src/TopBar.mdx +62 -0
  151. package/src/TopBar.stories.tsx +96 -0
  152. package/src/TopBar.tsx +57 -0
  153. package/src/cn.test.ts +27 -0
  154. package/src/cn.ts +25 -5
  155. package/src/index.ts +23 -0
  156. package/tailwind-preset.js +4 -3
  157. package/tokens.css +4 -0
package/src/Menu.mdx ADDED
@@ -0,0 +1,111 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as MenuStories from './Menu.stories'
3
+
4
+ <Meta of={MenuStories} />
5
+
6
+ # Menu
7
+
8
+ The shell every menu shares: an elevated container — hairline border, 8px
9
+ radius, 8px padding, the large shadow — and the two exits every menu owes,
10
+ **Escape and a click outside, owned here and never by the caller**. Rows are
11
+ **MenuItem**, headings are **MenuSection**, a non-interactive line at row
12
+ geometry is **MenuRow**, and **EnterHint** is the "↩ Enter" a picker row
13
+ shows while highlighted.
14
+
15
+ <Canvas of={MenuStories.Items} />
16
+
17
+ ## When
18
+
19
+ - A click-opened list of actions: a `…` more-menu, an account menu, a
20
+ right-click's worth of verbs. Rows are the default `MenuItem` — 14px
21
+ label, optional `leading` icon, `shortcut` kbd chip, `destructive` in the
22
+ error colour.
23
+ - A picker fed by typing: @-mentions, a people picker, a command list.
24
+ Those rows are `MenuItem size="tall"` — content height with a 40px floor,
25
+ room for a 32px face via `leading` and a `description` line — with an
26
+ `EnterHint` on the highlighted row.
27
+ - Grouped choices where one is current: `MenuSection` over rows, `selected`
28
+ on the chosen one.
29
+
30
+ <Canvas of={MenuStories.Sections} />
31
+
32
+ ## When not
33
+
34
+ - Picking one value for a form field → **Select**. It is the same shell
35
+ behaviour plus a trigger that shows the value.
36
+ - Rows that toggle checkboxes and stay open → that is a **listbox**, not a
37
+ menu; a Checkbox inside a `menuitem` button is invalid HTML. Build it as
38
+ a list in a DialogShell.
39
+ - A strip of icon actions (reactions, formatting) → a toolbar of
40
+ **IconButton**s.
41
+ - Anything with a title and a footer → **DialogShell**.
42
+ - Who-you-are and where-you-are (the avatar in the top bar) →
43
+ **IdentityMenu**, already built on this shell.
44
+
45
+ ## How
46
+
47
+ Menus are controlled: the caller keeps `open` state, renders the menu only
48
+ while true, and hands it `onClose`. Escape and outside-click then work
49
+ without the caller writing either.
50
+
51
+ ```tsx
52
+ import { Menu, MenuItem } from '@estiva-app/ui'
53
+
54
+ {open && (
55
+ <Menu onClose={() => setOpen(false)}>
56
+ <MenuItem label="Rename" onClick={rename} />
57
+ <MenuItem label="Delete" destructive onClick={confirmDelete} />
58
+ </Menu>
59
+ )}
60
+ ```
61
+
62
+ Two anchorings:
63
+
64
+ - **Default** — the menu hangs below its trigger, right-aligned; the
65
+ wrapper around trigger and menu must be `relative`.
66
+ - **`position={{ top, left }}` or `{ top, right }`** — the menu portals to
67
+ the body at fixed viewport coordinates, for triggers inside containers
68
+ that scroll or clip.
69
+
70
+ ## Keys
71
+
72
+ | Input | What happens |
73
+ |---|---|
74
+ | Escape | closes — the shell's listener, not the caller's |
75
+ | `mousedown` outside | closes; the trigger swallows its own (see below) |
76
+ | click inside the menu | stays open — the shell stops propagation |
77
+ | Tab / Shift-Tab | moves focus through the rows (native buttons) |
78
+ | Enter / Space on a focused row | activates it |
79
+ | Arrow keys | nothing — a typeahead picker owns its own highlight |
80
+
81
+ ### The trigger trap
82
+
83
+ The outside-click exit listens on `mousedown`. A trigger that toggles on
84
+ `click` will therefore see its own press close the menu and the click
85
+ reopen it — a flicker that reads as "the menu ignores me". The trigger
86
+ swallows the mousedown, exactly as IdentityMenu's does:
87
+
88
+ ```tsx
89
+ <button
90
+ onMouseDown={(event) => event.stopPropagation()}
91
+ onClick={() => setOpen((value) => !value)}
92
+ />
93
+ ```
94
+
95
+ ### What stays the caller's
96
+
97
+ Hover-opening, and submenus. A hover-opened menu owes the five popover
98
+ rules (see the README — stay open over the menu, close on leave, guard the
99
+ parent's hover); the shell gives only the two click-opened exits, so the
100
+ hover choreography is the caller's. Submenus likewise stay caller-owned —
101
+ if a second menu needs one, the shell learns it then.
102
+
103
+ ### Alignment
104
+
105
+ A menu whose rows are `px-3` (tall picker rows) aligns its headings by
106
+ passing `className="px-3"` to `MenuSection` — the heading row is `px-2` by
107
+ default, matching the default rows.
108
+
109
+ ## Props
110
+
111
+ <Controls of={MenuStories.Items} />
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconCopy, IconPencil, IconTrash } from '@tabler/icons-react'
3
+ import { Divider } from './Divider'
4
+ import { Menu, MenuItem, MenuRow, MenuSection } from './Menu'
5
+ import { SectionLabel } from './SectionLabel'
6
+
7
+ /**
8
+ * The shell every menu shares — container, rows, headings, and the two exits
9
+ * (Escape and a click outside) owned here, never by the caller. In an app
10
+ * the menu hangs from its trigger or a portal position; stories pin it open
11
+ * in-flow (`className="static"` wins over the anchoring) and centre it, so
12
+ * the docs page shows each one inside its own frame.
13
+ */
14
+ const meta = {
15
+ title: 'Overlays/Menu',
16
+ component: Menu,
17
+ decorators: [(Story) => <div className="flex min-h-[240px] w-full items-center justify-center"><Story /></div>],
18
+ args: { onClose: () => {}, children: null, className: 'static' },
19
+ argTypes: { position: { control: false }, onClose: { control: false }, children: { control: false } },
20
+ } satisfies Meta<typeof Menu>
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof meta>
24
+
25
+ /** Rows with a leading icon, a shortcut, and the destructive colour for the one that deletes. */
26
+ export const Items: Story = {
27
+ render: (args) => (
28
+ <Menu {...args}>
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={() => {}} />
31
+ <Divider className="my-1" />
32
+ <MenuItem label="Delete" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
33
+ </Menu>
34
+ ),
35
+ }
36
+
37
+ /** Groups under SectionLabel headings, with a Divider between them; `selected` marks the chosen value. */
38
+ export const Sections: Story = {
39
+ render: (args) => (
40
+ <Menu {...args}>
41
+ <MenuSection label="Sort by">
42
+ <MenuItem label="Newest first" selected onClick={() => {}} />
43
+ <MenuItem label="Oldest first" onClick={() => {}} />
44
+ </MenuSection>
45
+ <Divider className="my-1" />
46
+ <MenuSection label="Show">
47
+ <MenuItem label="Everything" onClick={() => {}} />
48
+ <MenuItem label="Unread only" onClick={() => {}} />
49
+ </MenuSection>
50
+ </Menu>
51
+ ),
52
+ }
53
+
54
+ /** A non-interactive row at the item's geometry — identity lines, hints. */
55
+ export const WithARow: Story = {
56
+ render: (args) => (
57
+ <Menu {...args} className="static w-64">
58
+ <MenuRow>
59
+ <span className="min-w-0 flex-1 truncate text-[14px] leading-[140%] text-text-primary">Ana Duarte</span>
60
+ <SectionLabel className="text-text-secondary">Owner</SectionLabel>
61
+ </MenuRow>
62
+ <Divider className="my-1" />
63
+ <MenuItem label="Sign out" onClick={() => {}} />
64
+ </Menu>
65
+ ),
66
+ }
package/src/Menu.tsx ADDED
@@ -0,0 +1,172 @@
1
+ import { useEffect, useRef, type ComponentPropsWithRef, type CSSProperties, type ReactNode } from 'react'
2
+ import { IconChevronRight } from '@tabler/icons-react'
3
+ import { createPortal } from 'react-dom'
4
+ import { cn } from './cn'
5
+ import { SectionLabel } from './SectionLabel'
6
+
7
+ /**
8
+ * THE menu shell (2026-09-01) — extracted once, for every menu in every app.
9
+ *
10
+ * Peek has no single Menu file; its topic menu, conversation menus, files
11
+ * menu and the top bar's account menu each hand-roll the same thing, and
12
+ * they disagree: some close on Escape, most do not, and row heights and
13
+ * minimum widths drift copy by copy. Ship extracted the shape so it would
14
+ * not become the next copy; the package exists so nobody becomes the one
15
+ * after that.
16
+ *
17
+ * What every menu shares, kept exactly: an elevated container with a
18
+ * hairline border, 8px radius, 8px padding and the large shadow; items that
19
+ * are 8px-radius rows, `px-2 py-1.5`, hover fill, 14px text, destructive
20
+ * ones in the error colour; section headings as a SectionLabel in a 32px
21
+ * row; and the two exits every menu has — Escape and a click outside —
22
+ * owned by the menu, never copied into a caller.
23
+ *
24
+ * Two anchorings: `position` portals it to the body at fixed viewport
25
+ * coordinates (for menus opened inside scrolling containers that clip);
26
+ * without it the menu hangs below its trigger, right-aligned, and the
27
+ * caller's wrapper is `relative`.
28
+ */
29
+ export interface MenuProps {
30
+ onClose: () => void
31
+ /** Viewport coordinates; the menu is portalled, hung from `top`, and aligned to whichever edge is given. */
32
+ position?: { top: number; right: number } | { top: number; left: number }
33
+ children: ReactNode
34
+ className?: string
35
+ }
36
+
37
+ export function Menu({ onClose, position, children, className }: MenuProps) {
38
+ const ref = useRef<HTMLDivElement>(null)
39
+
40
+ useEffect(() => {
41
+ const onDown = (event: MouseEvent) => {
42
+ if (!ref.current?.contains(event.target as Node)) onClose()
43
+ }
44
+ const onKey = (event: KeyboardEvent) => {
45
+ if (event.key === 'Escape') onClose()
46
+ }
47
+ document.addEventListener('mousedown', onDown)
48
+ document.addEventListener('keydown', onKey)
49
+ return () => {
50
+ document.removeEventListener('mousedown', onDown)
51
+ document.removeEventListener('keydown', onKey)
52
+ }
53
+ }, [onClose])
54
+
55
+ const style: CSSProperties | undefined = position ? { top: position.top, ...('left' in position ? { left: position.left } : { right: position.right }) } : undefined
56
+ const node = (
57
+ <div
58
+ ref={ref}
59
+ role="menu"
60
+ data-interactive
61
+ className={cn(
62
+ 'z-50 flex min-w-[180px] flex-col rounded-lg border border-border-default bg-bg-elevated p-2 shadow-lg',
63
+ position ? 'fixed' : 'absolute right-0 top-full mt-1',
64
+ className,
65
+ )}
66
+ style={style}
67
+ onClick={(event) => event.stopPropagation()}
68
+ >
69
+ {children}
70
+ </div>
71
+ )
72
+ return position ? createPortal(node, document.body) : node
73
+ }
74
+
75
+ export interface MenuItemProps extends Omit<ComponentPropsWithRef<'button'>, 'children'> {
76
+ label?: string
77
+ /** Replaces the label/description block — for rows whose middle is richer
78
+ * than text (the launcher's form rows). Leading/trailing still apply. */
79
+ children?: ReactNode
80
+ /** `tall` is the picker row — content height with a 40px floor, px-3,
81
+ * gap-3, room for a 32px face or icon tile and the description line.
82
+ * `default` is the command row. */
83
+ size?: 'default' | 'tall'
84
+ /** A second line under the label — a role, an address — 12px, secondary, truncating. */
85
+ description?: string
86
+ /** Before the label: a 16px icon (stroke 1.5, secondary), or an Avatar, for rows led by a face. */
87
+ leading?: ReactNode
88
+ /** At the right edge: a hint, a value — anything. Wins over `shortcut` and `submenu`. */
89
+ trailing?: ReactNode
90
+ /** A keyboard hint, drawn as the kbd chip. */
91
+ shortcut?: string
92
+ /** The row opens another menu: draws the chevron at the right edge. */
93
+ submenu?: boolean
94
+ destructive?: boolean
95
+ /** The row the menu currently points at (a submenu's chosen value). */
96
+ selected?: boolean
97
+ }
98
+
99
+ export function MenuItem({ label, children, size = 'default', description, leading, trailing, shortcut, submenu, destructive, selected, className, ...props }: MenuItemProps) {
100
+ const edge =
101
+ trailing ??
102
+ (shortcut ? (
103
+ <kbd className="inline-flex shrink-0 items-center justify-center rounded-sm border border-border-strong bg-bg-inset px-1 py-px text-caption text-text-secondary">
104
+ {shortcut}
105
+ </kbd>
106
+ ) : submenu ? (
107
+ <IconChevronRight size={16} stroke={1.5} className="shrink-0 text-text-muted" />
108
+ ) : null)
109
+ return (
110
+ <button
111
+ type="button"
112
+ role="menuitem"
113
+ className={cn(
114
+ 'flex w-full cursor-pointer items-center rounded-lg text-left hover:bg-bg-hover transition-colors',
115
+ // tall: as tall as its content, never shorter than 40px (Katerina,
116
+ // 2026-09-01) — a single-line picker row sits at 40, a row with a
117
+ // 32px face and a role line comes out at its natural 48. One rule,
118
+ // not a hand-picked height per file.
119
+ size === 'tall' ? 'min-h-10 gap-3 px-3 py-1.5' : 'gap-2 px-2 py-1.5',
120
+ selected && 'bg-bg-hover',
121
+ className,
122
+ )}
123
+ {...props}
124
+ >
125
+ {leading && <span className="flex shrink-0 items-center">{leading}</span>}
126
+ {/* Sizes are arbitrary values (the body-2 and caption tokens): these
127
+ lists merge with a colour, and tw-merge drops a token size beside a
128
+ colour. Ship's copy said `text-sm`, which was never the ramp. */}
129
+ {children ?? (
130
+ <span className={cn('flex min-w-0 flex-1 flex-col', size === 'tall' && 'gap-[2px]')}>
131
+ <span className={cn('truncate text-[14px] leading-[140%]', destructive ? 'text-error-default' : 'text-text-primary')}>
132
+ {label}
133
+ </span>
134
+ {description && <span className="truncate text-[12px] leading-[120%] text-text-secondary">{description}</span>}
135
+ </span>
136
+ )}
137
+ {edge && <span className="flex shrink-0 items-center">{edge}</span>}
138
+ </button>
139
+ )
140
+ }
141
+
142
+ /**
143
+ * The keyboard hint a picker row shows while highlighted — "↩ Enter",
144
+ * "↩ #topic" — hand-rolled in five files before this (2026-09-01).
145
+ */
146
+ export function EnterHint({ label = 'Enter' }: { label?: string }) {
147
+ return (
148
+ <span className="flex shrink-0 items-center gap-2 text-text-muted">
149
+ <span className="text-[12px] leading-[120%]">↩</span>
150
+ <span className="text-[9px] font-medium leading-[115%] signal:font-mono signal:text-[9.5px] signal:tracking-[0.04em]">{label}</span>
151
+ </span>
152
+ )
153
+ }
154
+
155
+ /** A section heading inside a menu: the 32px row with a SectionLabel, read
156
+ * secondary — a heading inside a menu labels the rows, it is not one of
157
+ * them (Katerina, 2026-09-01). */
158
+ export function MenuSection({ label, children, className }: { label: string; children: ReactNode; /** On the heading row — a surface whose rows are px-3 aligns its heading with px-3. */ className?: string }) {
159
+ return (
160
+ <div className="flex flex-col">
161
+ <div className={cn('flex h-8 items-center px-2', className)}>
162
+ <SectionLabel className="text-text-secondary">{label}</SectionLabel>
163
+ </div>
164
+ {children}
165
+ </div>
166
+ )
167
+ }
168
+
169
+ /** A non-interactive row inside a menu, at the item's own geometry. */
170
+ export function MenuRow({ children, className }: { children: ReactNode; className?: string }) {
171
+ return <div className={cn('flex items-center gap-2 px-2 py-1.5', className)}>{children}</div>
172
+ }
@@ -0,0 +1,55 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as MenuItemStories from './MenuItem.stories'
3
+
4
+ <Meta of={MenuItemStories} />
5
+
6
+ # MenuItem
7
+
8
+ The row of a menu: label, and around it everything a row can carry — a
9
+ `leading` icon or face, a `description` second line, a `shortcut` kbd
10
+ chip, a `submenu` chevron, a `trailing` anything (which wins over the
11
+ other two), `destructive` in the error colour, `selected` as the fill.
12
+
13
+ <Canvas of={MenuItemStories.AllVariants} />
14
+
15
+ ## When
16
+
17
+ - Every actionable row inside a **Menu**.
18
+ - **`size="tall"`** is the picker row: content height with a 40px floor,
19
+ `px-3`, room for a 32px face and the description line — one rule, not a
20
+ hand-picked height per surface. ChipInput's suggestions are these.
21
+ - `children` replaces the label/description block for rows whose middle
22
+ is richer than text; `leading` and `trailing` still apply.
23
+
24
+ <Canvas of={MenuItemStories.Tall} />
25
+
26
+ ## When not
27
+
28
+ - Outside a menu — it renders `role="menuitem"` and belongs inside a
29
+ `role="menu"` container.
30
+ - Picking a form value → **Select**'s own options.
31
+ - A row that toggles a checkbox → not a menu at all; see the Menu page.
32
+
33
+ ## How
34
+
35
+ ```tsx
36
+ import { Menu, MenuItem } from '@estiva-app/ui'
37
+ import { IconPencil } from '@tabler/icons-react'
38
+
39
+ <MenuItem
40
+ label="Rename"
41
+ leading={<IconPencil size={16} stroke={1.5} className="text-text-secondary" />}
42
+ shortcut="⌘ R"
43
+ onClick={rename}
44
+ />
45
+ ```
46
+
47
+ - It is a native button underneath — every prop passes through, ref
48
+ included, so a typeahead can drive the highlight with `selected` +
49
+ `onMouseEnter` and add on `onMouseDown`.
50
+ - A destructive row's `leading` icon takes the error colour itself
51
+ (`text-error-default`) — the row colours only its label.
52
+
53
+ ## Props
54
+
55
+ <Controls of={MenuItemStories.Default} />
@@ -0,0 +1,98 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconCopy, IconPin, IconTrash } from '@tabler/icons-react'
3
+ import { Avatar } from './Avatar'
4
+ import { EnterHint, Menu, MenuItem } from './Menu'
5
+
6
+ /**
7
+ * One row of a menu. Anatomy: `leading` (a 16px icon or an Avatar) · label
8
+ * with an optional `description` line · one thing at the right edge —
9
+ * `trailing` (any hint), else `shortcut` (the kbd chip), else the `submenu`
10
+ * chevron. Stories sit inside a pinned-open Menu so the row is seen on the
11
+ * surface it lives on.
12
+ */
13
+ const meta = {
14
+ title: 'Overlays/MenuItem',
15
+ component: MenuItem,
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="flex min-h-[120px] w-full items-center justify-center">
19
+ <Menu onClose={() => {}} className="static w-72">
20
+ <Story />
21
+ </Menu>
22
+ </div>
23
+ ),
24
+ ],
25
+ args: { label: 'Rename', onClick: () => {} },
26
+ argTypes: {
27
+ onClick: { control: false },
28
+ leading: { control: false },
29
+ trailing: { control: false },
30
+ },
31
+ } satisfies Meta<typeof MenuItem>
32
+
33
+ export default meta
34
+ type Story = StoryObj<typeof meta>
35
+
36
+ export const Default: Story = {}
37
+
38
+ export const WithAnIcon: Story = {
39
+ args: { label: 'Pin', leading: <IconPin size={16} stroke={1.5} className="text-text-secondary" /> },
40
+ }
41
+
42
+ export const WithAShortcut: Story = {
43
+ args: { label: 'Copy link', leading: <IconCopy size={16} stroke={1.5} className="text-text-secondary" />, shortcut: '⌘ C' },
44
+ }
45
+
46
+ /** The row opens another menu. */
47
+ export const Submenu: Story = {
48
+ args: { label: 'Move to…', submenu: true },
49
+ }
50
+
51
+ /** A person as a row — the face, the name, a second line, a trailing hint. Peek's mention rows. */
52
+ export const APerson: Story = {
53
+ args: {
54
+ label: 'Ana Duarte',
55
+ description: 'Product designer',
56
+ leading: <Avatar name="Ana Duarte" size={32} />,
57
+ trailing: <span className="text-[12px] leading-[120%] text-text-muted">↩</span>,
58
+ },
59
+ }
60
+
61
+ /** The submenu's chosen value. */
62
+ export const Selected: Story = {
63
+ args: { label: 'Newest first', selected: true },
64
+ }
65
+
66
+ export const Destructive: Story = {
67
+ args: { label: 'Delete', destructive: true, leading: <IconTrash size={16} stroke={1.5} className="text-error-default" /> },
68
+ }
69
+
70
+ /** Every shape on one canvas. */
71
+ export const AllVariants: Story = {
72
+ parameters: { controls: { disable: true } },
73
+ render: () => (
74
+ <>
75
+ <MenuItem label="Bare" onClick={() => {}} />
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={() => {}} />
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={() => {}} />
81
+ <MenuItem label="The chosen value" selected onClick={() => {}} />
82
+ <MenuItem label="Destructive" destructive leading={<IconTrash size={16} stroke={1.5} className="text-error-default" />} onClick={() => {}} />
83
+ <MenuItem label="A very long label that runs out of room and truncates" onClick={() => {}} />
84
+ </>
85
+ ),
86
+ }
87
+
88
+ /** The tall picker row — 48px, px-3, a 32px face or tile, the description line, and the hint while highlighted. TopicMenu, MentionMenu, the files menu and the launcher all draw this row. */
89
+ export const Tall: Story = {
90
+ args: {
91
+ size: 'tall',
92
+ label: 'Ana Duarte',
93
+ description: 'Product designer',
94
+ leading: <Avatar name="Ana Duarte" size={32} />,
95
+ selected: true,
96
+ trailing: <EnterHint />,
97
+ },
98
+ }
@@ -0,0 +1,53 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as NavItemStories from './NavItem.stories'
3
+
4
+ <Meta of={NavItemStories} />
5
+
6
+ # NavItem
7
+
8
+ One row of a sidebar: an optional 16px icon, a label, and an optional
9
+ count — a muted mono number whose tooltip says what it counts.
10
+
11
+ <Canvas of={NavItemStories.WithIconAndCount} />
12
+
13
+ ## When
14
+
15
+ - The entries of a **Sidebar** — top-level places, and the rows of a
16
+ labelled group.
17
+ - `active` marks where the route is: the selected tab's neutral fill, and
18
+ `aria-current="page"` for assistive tech.
19
+ - A count is a number of **one** thing, and `countLabel` says which ("18
20
+ open", "5 active"). A `0` or an absent count draws nothing — that is
21
+ this row's rule; a *tab* draws its zero, and both rulings stand.
22
+
23
+ <Canvas of={NavItemStories.ZeroDrawsNothing} />
24
+
25
+ ## When not
26
+
27
+ - An icon rail's tile → **RailItem**.
28
+ - Switching views inside a page → **Tabs**.
29
+ - A row in a popover → **MenuItem**.
30
+
31
+ ## How
32
+
33
+ ```tsx
34
+ import { NavItem } from '@estiva-app/ui'
35
+
36
+ <NavItem
37
+ href="#/documents"
38
+ label="Documents"
39
+ icon={<IconListDetails size={16} stroke={1.5} />}
40
+ count={openCount}
41
+ countLabel={`${openCount} open`}
42
+ active={current === 'documents'}
43
+ />
44
+ ```
45
+
46
+ - It renders a plain anchor. A router app keeps a thin wrapper: compute
47
+ `active` from the location, intercept the click, navigate — every
48
+ anchor prop passes through.
49
+ - The label truncates; the count never gives up its width.
50
+
51
+ ## Props
52
+
53
+ <Controls of={NavItemStories.Default} />
@@ -0,0 +1,36 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { IconSquareRounded } from '@tabler/icons-react'
3
+ import { NavItem } from './NavItem'
4
+
5
+ const placeholder = <IconSquareRounded size={16} stroke={1.5} />
6
+
7
+ const meta = {
8
+ title: 'Frame/NavItem',
9
+ component: NavItem,
10
+ args: { label: 'Item', href: '#', active: false },
11
+ argTypes: { icon: { control: false } },
12
+ decorators: [(Story) => <div className="flex w-60 flex-col gap-px">{Story()}</div>],
13
+ } satisfies Meta<typeof NavItem>
14
+
15
+ export default meta
16
+ type Story = StoryObj<typeof meta>
17
+
18
+ export const Default: Story = {}
19
+
20
+ /** Active is the selected tab's fill — neutral, text primary. */
21
+ export const Active: Story = { args: { active: true } }
22
+
23
+ /** The count is a muted mono number, and its tooltip says what it counts. */
24
+ export const WithIconAndCount: Story = {
25
+ args: { icon: placeholder, count: 18, countLabel: '18 open' },
26
+ }
27
+
28
+ /** A zero is not drawn at all — this row's rule (a tab draws its zero; both are deliberate). */
29
+ export const ZeroDrawsNothing: Story = {
30
+ args: { icon: placeholder, count: 0, countLabel: '0 open' },
31
+ }
32
+
33
+ /** The label gives way; the count never does. */
34
+ export const LongLabelTruncates: Story = {
35
+ args: { label: 'An item whose label runs much longer than the column has room for', count: 7, countLabel: '7 open' },
36
+ }
@@ -0,0 +1,53 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from 'react'
2
+ import { cn } from './cn'
3
+ import { WithTooltip } from './Tooltip'
4
+
5
+ /**
6
+ * One row of a sidebar: a label, an optional count, an active state
7
+ * (Ship's NavItem, 2026-09-02, verbatim).
8
+ *
9
+ * A count is a number of ONE thing, and its tooltip says which ("18
10
+ * open", "5 active") — the caller passes the number and the words for it,
11
+ * and a zero is not drawn at all. That is this row's rule; a *tab's*
12
+ * count draws its zero (see Tabs) — both rulings stand, deliberately.
13
+ *
14
+ * Active is the same fill as a selected tab — `bg-active`, neutral.
15
+ */
16
+ export interface NavItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'> {
17
+ label: string
18
+ href: string
19
+ /** `0` or absent renders no counter. */
20
+ count?: number
21
+ /** What the count counts, for the tooltip — "18 open", "5 active". */
22
+ countLabel?: string
23
+ active?: boolean
24
+ /** 16px, stroke 1.5. */
25
+ icon?: ReactNode
26
+ className?: string
27
+ }
28
+
29
+ export function NavItem({ label, href, count, countLabel, active = false, icon, className, ...props }: NavItemProps) {
30
+ return (
31
+ <a
32
+ href={href}
33
+ aria-current={active ? 'page' : undefined}
34
+ className={cn(
35
+ // shrink-0: the row keeps its 32px inside an overflowing flex column
36
+ // — without it the list compresses instead of scrolling (the missing-
37
+ // shrink-0 family; Katerina, 2026-09-02).
38
+ 'flex h-8 min-w-0 shrink-0 items-center gap-2 rounded-md px-2 text-body-2 transition-colors',
39
+ active ? 'bg-bg-active text-text-primary' : 'text-text-secondary hover:bg-bg-hover hover:text-text-primary',
40
+ className,
41
+ )}
42
+ {...props}
43
+ >
44
+ {icon && <span className="flex shrink-0 items-center">{icon}</span>}
45
+ <span className="flex-1 truncate">{label}</span>
46
+ {count ? (
47
+ <WithTooltip label={countLabel ?? `${count} open`}>
48
+ <span className="shrink-0 font-mono text-caption tabular-nums text-text-muted">{count}</span>
49
+ </WithTooltip>
50
+ ) : null}
51
+ </a>
52
+ )
53
+ }