@estiva-app/ui 0.1.0 → 0.3.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/README.md +12 -9
- package/base.css +43 -0
- package/dist/AppShell.d.ts +39 -0
- package/dist/AppShell.d.ts.map +1 -0
- package/dist/Avatar.d.ts +7 -2
- package/dist/Avatar.d.ts.map +1 -1
- package/dist/AvatarGroup.d.ts +23 -0
- package/dist/AvatarGroup.d.ts.map +1 -0
- package/dist/Banner.d.ts +19 -0
- package/dist/Banner.d.ts.map +1 -0
- package/dist/Breadcrumb.d.ts +28 -0
- package/dist/Breadcrumb.d.ts.map +1 -0
- package/dist/Checkbox.d.ts +18 -0
- package/dist/Checkbox.d.ts.map +1 -0
- package/dist/ChipInput.d.ts +57 -0
- package/dist/ChipInput.d.ts.map +1 -0
- package/dist/ConfirmDialog.d.ts +22 -0
- package/dist/ConfirmDialog.d.ts.map +1 -0
- package/dist/DialogShell.d.ts +9 -2
- package/dist/DialogShell.d.ts.map +1 -1
- package/dist/EditableText.d.ts +56 -0
- package/dist/EditableText.d.ts.map +1 -0
- package/dist/IdentityMenu.d.ts +58 -0
- package/dist/IdentityMenu.d.ts.map +1 -0
- package/dist/Menu.d.ts +82 -0
- package/dist/Menu.d.ts.map +1 -0
- package/dist/NavItem.d.ts +26 -0
- package/dist/NavItem.d.ts.map +1 -0
- package/dist/Person.d.ts +26 -0
- package/dist/Person.d.ts.map +1 -0
- package/dist/PersonTrigger.d.ts +23 -0
- package/dist/PersonTrigger.d.ts.map +1 -0
- package/dist/Property.d.ts +24 -0
- package/dist/Property.d.ts.map +1 -0
- package/dist/Rail.d.ts +18 -0
- package/dist/Rail.d.ts.map +1 -0
- package/dist/RailItem.d.ts +23 -0
- package/dist/RailItem.d.ts.map +1 -0
- package/dist/SearchInput.d.ts +21 -0
- package/dist/SearchInput.d.ts.map +1 -0
- package/dist/SectionHeader.d.ts +34 -0
- package/dist/SectionHeader.d.ts.map +1 -0
- package/dist/SectionLabel.d.ts +19 -0
- package/dist/SectionLabel.d.ts.map +1 -0
- package/dist/Select.d.ts +42 -5
- package/dist/Select.d.ts.map +1 -1
- package/dist/Sidebar.d.ts +18 -0
- package/dist/Sidebar.d.ts.map +1 -0
- package/dist/Tabs.d.ts +34 -0
- package/dist/Tabs.d.ts.map +1 -0
- package/dist/Toast.d.ts +48 -0
- package/dist/Toast.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +3 -1
- package/dist/Tooltip.d.ts.map +1 -1
- package/dist/TopBar.d.ts +33 -0
- package/dist/TopBar.d.ts.map +1 -0
- package/dist/cn.d.ts +14 -4
- package/dist/cn.d.ts.map +1 -1
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1254 -106
- package/dist/index.js.map +4 -4
- package/package.json +5 -2
- package/src/AppShell.mdx +66 -0
- package/src/AppShell.stories.tsx +117 -0
- package/src/AppShell.tsx +77 -0
- package/src/Avatar.initials.test.ts +21 -0
- package/src/Avatar.mdx +49 -0
- package/src/Avatar.tsx +12 -9
- package/src/AvatarGroup.mdx +39 -0
- package/src/AvatarGroup.stories.tsx +56 -0
- package/src/AvatarGroup.tsx +42 -0
- package/src/Banner.mdx +45 -0
- package/src/Banner.stories.tsx +35 -0
- package/src/Banner.tsx +35 -0
- package/src/Breadcrumb.mdx +52 -0
- package/src/Breadcrumb.stories.tsx +42 -0
- package/src/Breadcrumb.tsx +106 -0
- package/src/Button.mdx +57 -0
- package/src/Checkbox.mdx +44 -0
- package/src/Checkbox.stories.tsx +49 -0
- package/src/Checkbox.tsx +46 -0
- package/src/Chip.mdx +43 -0
- package/src/ChipInput.mdx +68 -0
- package/src/ChipInput.stories.tsx +99 -0
- package/src/ChipInput.tsx +247 -0
- package/src/ConfirmDialog.mdx +51 -0
- package/src/ConfirmDialog.stories.tsx +63 -0
- package/src/ConfirmDialog.tsx +55 -0
- package/src/DialogShell.mdx +68 -0
- package/src/DialogShell.tsx +12 -5
- package/src/Divider.mdx +41 -0
- package/src/EditableText.mdx +59 -0
- package/src/EditableText.stories.tsx +63 -0
- package/src/EditableText.tsx +169 -0
- package/src/EmptyState.mdx +41 -0
- package/src/Field.mdx +39 -0
- package/src/IconButton.mdx +47 -0
- package/src/IdentityMenu.mdx +62 -0
- package/src/IdentityMenu.stories.tsx +52 -0
- package/src/IdentityMenu.tsx +159 -0
- package/src/Menu.mdx +111 -0
- package/src/Menu.stories.tsx +66 -0
- package/src/Menu.tsx +172 -0
- package/src/MenuItem.mdx +55 -0
- package/src/MenuItem.stories.tsx +98 -0
- package/src/NavItem.mdx +53 -0
- package/src/NavItem.stories.tsx +36 -0
- package/src/NavItem.tsx +53 -0
- package/src/Person.mdx +44 -0
- package/src/Person.stories.tsx +43 -0
- package/src/Person.tsx +39 -0
- package/src/PersonTrigger.mdx +51 -0
- package/src/PersonTrigger.stories.tsx +30 -0
- package/src/PersonTrigger.tsx +62 -0
- package/src/Property.mdx +51 -0
- package/src/Property.stories.tsx +91 -0
- package/src/Property.tsx +44 -0
- package/src/Rail.tsx +26 -0
- package/src/RailItem.mdx +50 -0
- package/src/RailItem.stories.tsx +37 -0
- package/src/RailItem.tsx +53 -0
- package/src/SearchInput.mdx +39 -0
- package/src/SearchInput.stories.tsx +24 -0
- package/src/SearchInput.tsx +45 -0
- package/src/SectionHeader.mdx +52 -0
- package/src/SectionHeader.stories.tsx +66 -0
- package/src/SectionHeader.tsx +85 -0
- package/src/SectionLabel.mdx +42 -0
- package/src/SectionLabel.stories.tsx +31 -0
- package/src/SectionLabel.tsx +28 -0
- package/src/Select.fit.test.ts +97 -0
- package/src/Select.mdx +71 -0
- package/src/Select.stories.tsx +95 -0
- package/src/Select.tsx +123 -15
- package/src/Sidebar.mdx +56 -0
- package/src/Sidebar.stories.tsx +50 -0
- package/src/Sidebar.tsx +32 -0
- package/src/Skeleton.mdx +39 -0
- package/src/Tabs.mdx +53 -0
- package/src/Tabs.stories.tsx +91 -0
- package/src/Tabs.tsx +73 -0
- package/src/TextInput.mdx +39 -0
- package/src/Textarea.mdx +36 -0
- package/src/Toast.mdx +54 -0
- package/src/Toast.stories.tsx +87 -0
- package/src/Toast.tsx +169 -0
- package/src/Tooltip.mdx +51 -0
- package/src/Tooltip.tsx +4 -2
- package/src/TopBar.mdx +62 -0
- package/src/TopBar.stories.tsx +96 -0
- package/src/TopBar.tsx +57 -0
- package/src/cn.test.ts +27 -0
- package/src/cn.ts +25 -5
- package/src/index.ts +23 -0
- package/tailwind-preset.js +4 -3
- 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
|
+
}
|
package/src/MenuItem.mdx
ADDED
|
@@ -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
|
+
}
|
package/src/NavItem.mdx
ADDED
|
@@ -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
|
+
}
|
package/src/NavItem.tsx
ADDED
|
@@ -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
|
+
}
|