@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/Person.mdx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PersonStories from './Person.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PersonStories} />
|
|
5
|
+
|
|
6
|
+
# Person
|
|
7
|
+
|
|
8
|
+
A face beside a name — the inline mention of a human. The gap between face
|
|
9
|
+
and name is one rule, 8px, wherever a person appears.
|
|
10
|
+
|
|
11
|
+
<Canvas of={PersonStories.Sizes} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- A person named inline: an assignee, an author, a participant, a value in
|
|
16
|
+
a property row.
|
|
17
|
+
- Someone with no published name shows the `fallback` — an em dash by
|
|
18
|
+
default, the same mark an empty property uses. Pass `"Anonymous"` where
|
|
19
|
+
the unnamed person is the viewer themself.
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- The face alone → **Avatar**.
|
|
24
|
+
- The person as a button that opens a menu → **PersonTrigger**.
|
|
25
|
+
- A person as a row of a picker → **MenuItem** with an Avatar in `leading`
|
|
26
|
+
and a `description`.
|
|
27
|
+
|
|
28
|
+
## How
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
import { Person } from '@estiva-app/ui'
|
|
32
|
+
|
|
33
|
+
<Person name={p.name} picture={p.picture} size={20} />
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- Hand it strings — a name and a picture URL. It takes no key on purpose:
|
|
37
|
+
a component that accepted one would eventually show one.
|
|
38
|
+
- It sets no text size of its own; the words inherit the caller's, so face
|
|
39
|
+
and words are always set together (the Sizes story shows the pairings).
|
|
40
|
+
- The name truncates; give the container `min-w-0` room to let it.
|
|
41
|
+
|
|
42
|
+
## Props
|
|
43
|
+
|
|
44
|
+
<Controls of={PersonStories.Named} />
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Person } from './Person'
|
|
3
|
+
|
|
4
|
+
/** A face beside a name — never a key. */
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Primitives/Person',
|
|
7
|
+
component: Person,
|
|
8
|
+
args: { name: 'Ana Duarte', size: 20 },
|
|
9
|
+
} satisfies Meta<typeof Person>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
export const Named: Story = {}
|
|
15
|
+
|
|
16
|
+
/** Nobody has published a name: the silhouette and the em dash, muted — the same mark a property with no value uses. */
|
|
17
|
+
export const Unnamed: Story = {
|
|
18
|
+
args: { name: undefined },
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Where the unnamed person is *you*, pass a word instead of the dash. */
|
|
22
|
+
export const OwnFallback: Story = {
|
|
23
|
+
args: { name: undefined, fallback: 'Anonymous' },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The text size is the caller's, so face and words are set together. */
|
|
27
|
+
export const Sizes: Story = {
|
|
28
|
+
parameters: { controls: { disable: true } },
|
|
29
|
+
render: () => (
|
|
30
|
+
<div className="flex flex-col items-start gap-3">
|
|
31
|
+
<Person name="Ana Duarte" size={16} className="text-caption" />
|
|
32
|
+
<Person name="Ana Duarte" size={20} className="text-body-2" />
|
|
33
|
+
<Person name="Ravi Mehta" size={22} className="text-body-2" />
|
|
34
|
+
<Person name="Ravi Mehta" size={28} className="text-body-1" />
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A narrow spot: the name truncates, the face never does. */
|
|
40
|
+
export const Truncating: Story = {
|
|
41
|
+
decorators: [(Story) => <div className="w-[120px]"><Story /></div>],
|
|
42
|
+
args: { name: 'Ana Duarte de Almeida e Costa' },
|
|
43
|
+
}
|
package/src/Person.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { cn } from './cn'
|
|
2
|
+
import { Avatar } from './Avatar'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A face beside a name. Ship's Person (2026-09-01), which was Peek's minus
|
|
6
|
+
* the projection type it took its profile from: this one is handed the two
|
|
7
|
+
* strings and nothing else, so it cannot be handed a key. Peek keeps a
|
|
8
|
+
* wrapper that resolves the profile, the way its Avatar wrapper resolves the
|
|
9
|
+
* picture.
|
|
10
|
+
*
|
|
11
|
+
* A face beside a name needs no caption (Peek's rulings), and someone nobody
|
|
12
|
+
* has published a name for gets the `fallback` — an em dash by default, the
|
|
13
|
+
* same mark a property with no value uses — rather than the key back on
|
|
14
|
+
* screen to say it. Pass "Anonymous" where the unnamed person is *you*, and
|
|
15
|
+
* you know who that is.
|
|
16
|
+
*
|
|
17
|
+
* The text size is the caller's, so the face and the words are always set
|
|
18
|
+
* together — see the Sizes story.
|
|
19
|
+
*/
|
|
20
|
+
export interface PersonProps {
|
|
21
|
+
name?: string
|
|
22
|
+
picture?: string
|
|
23
|
+
/** Shown in place of a missing name. */
|
|
24
|
+
fallback?: string
|
|
25
|
+
size?: number
|
|
26
|
+
className?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Person({ name, picture, fallback = '—', size = 20, className }: PersonProps) {
|
|
30
|
+
return (
|
|
31
|
+
// gap-2: the face-to-name distance is one rule (8px) wherever a person
|
|
32
|
+
// appears — Katerina, 2026-09-01, set on Person so PersonTrigger and every
|
|
33
|
+
// row agree by construction.
|
|
34
|
+
<span className={cn('flex min-w-0 items-center gap-2', className)}>
|
|
35
|
+
<Avatar name={name} src={picture} size={size} />
|
|
36
|
+
<span className={cn('truncate', !name && 'text-text-muted')}>{name ?? fallback}</span>
|
|
37
|
+
</span>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PersonTriggerStories from './PersonTrigger.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PersonTriggerStories} />
|
|
5
|
+
|
|
6
|
+
# PersonTrigger
|
|
7
|
+
|
|
8
|
+
The person, as the button that opens a menu. Two shapes, one component:
|
|
9
|
+
the **row** — face · name · 14px chevron on a 32px button with a hover
|
|
10
|
+
fill — and **`compact`** — the face alone, 36px, no chevron and no hover
|
|
11
|
+
fill, because the face fills the whole control.
|
|
12
|
+
|
|
13
|
+
<Canvas of={PersonTriggerStories.Row} />
|
|
14
|
+
|
|
15
|
+
<Canvas of={PersonTriggerStories.Compact} />
|
|
16
|
+
|
|
17
|
+
## When
|
|
18
|
+
|
|
19
|
+
- The account trigger in a top bar — either shape.
|
|
20
|
+
- Any control whose meaning is "this person, opened".
|
|
21
|
+
- What it opens is not its business: pair it with **IdentityMenu** for the
|
|
22
|
+
ready-made account menu, or your own **Menu**.
|
|
23
|
+
|
|
24
|
+
## When not
|
|
25
|
+
|
|
26
|
+
- Showing a person without opening anything → **Person**.
|
|
27
|
+
- Choosing a person from a list → **Select** or **ChipInput**.
|
|
28
|
+
|
|
29
|
+
## How
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { PersonTrigger } from '@estiva-app/ui'
|
|
33
|
+
|
|
34
|
+
<PersonTrigger name={me.name} picture={me.picture} open={open}
|
|
35
|
+
onMouseDown={(e) => e.stopPropagation()} onClick={() => setOpen((v) => !v)} />
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `open` holds the row's hover fill while the menu is up, and feeds
|
|
39
|
+
`aria-expanded`.
|
|
40
|
+
- **The accessible name**: the row shape is named by the visible name —
|
|
41
|
+
do **not** add an `aria-label`, it would override the person. The
|
|
42
|
+
`compact` face has no visible text, so there an `aria-label` is
|
|
43
|
+
required.
|
|
44
|
+
- A toggle trigger must swallow its `mousedown` (as above) or the menu's
|
|
45
|
+
outside-click dismiss turns each press into close-then-reopen — the
|
|
46
|
+
trap is spelled out on the Menu page.
|
|
47
|
+
- It takes a `ref` and every native button prop — anchor your menu on it.
|
|
48
|
+
|
|
49
|
+
## Props
|
|
50
|
+
|
|
51
|
+
<Controls of={PersonTriggerStories.Row} />
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { PersonTrigger } from './PersonTrigger'
|
|
3
|
+
|
|
4
|
+
/** The person, as the button that opens the account menu. The menu itself stays in the app. */
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Primitives/PersonTrigger',
|
|
7
|
+
component: PersonTrigger,
|
|
8
|
+
args: { name: 'Ana Duarte', open: false, compact: false },
|
|
9
|
+
} satisfies Meta<typeof PersonTrigger>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
/** The row shape — face · name · chevron on a 32px button, Ship's top bar. */
|
|
15
|
+
export const Row: Story = {}
|
|
16
|
+
|
|
17
|
+
/** While what it opens is open, the row holds its hover fill. */
|
|
18
|
+
export const RowOpen: Story = {
|
|
19
|
+
args: { open: true },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The row for someone with no published name. */
|
|
23
|
+
export const RowUnnamed: Story = {
|
|
24
|
+
args: { name: undefined, fallback: 'Anonymous' },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The face alone — Peek's top-bar shape. No chevron, no padding, and no hover fill: the face fills the whole control, so a fill would have nowhere to show. */
|
|
28
|
+
export const Compact: Story = {
|
|
29
|
+
args: { compact: true },
|
|
30
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ComponentPropsWithRef } from 'react'
|
|
2
|
+
import { IconChevronDown } from '@tabler/icons-react'
|
|
3
|
+
import { cn } from './cn'
|
|
4
|
+
import { Avatar } from './Avatar'
|
|
5
|
+
import { Person, type PersonProps } from './Person'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The person, as the button that opens the account menu.
|
|
9
|
+
*
|
|
10
|
+
* Two shapes, one component (Katerina, 2026-09-01):
|
|
11
|
+
*
|
|
12
|
+
* - the row — face · name · chevron on a 32px button with a hover fill, as
|
|
13
|
+
* Ship's top bar draws it. The chevron is 14px, the small-control size.
|
|
14
|
+
* - `compact` — the face alone, as Peek's top bar draws it: no chevron, no
|
|
15
|
+
* padding, and no hover fill, because the face fills the whole control and
|
|
16
|
+
* a fill would have nowhere to show.
|
|
17
|
+
*
|
|
18
|
+
* What it opens — the menu and everything in it — stays in the app.
|
|
19
|
+
*/
|
|
20
|
+
export interface PersonTriggerProps
|
|
21
|
+
extends Omit<PersonProps, 'className'>,
|
|
22
|
+
Omit<ComponentPropsWithRef<'button'>, 'children'> {
|
|
23
|
+
/** Whether what it opens is open — the row shape holds its hover fill while so. */
|
|
24
|
+
open?: boolean
|
|
25
|
+
/** Face only — no chevron, no hover fill. Defaults the face to 36px; the row shape to 22px. */
|
|
26
|
+
compact?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function PersonTrigger({ name, picture, fallback, size, open = false, compact = false, className, ...props }: PersonTriggerProps) {
|
|
30
|
+
if (compact) {
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
aria-haspopup="menu"
|
|
35
|
+
aria-expanded={open}
|
|
36
|
+
className={cn('cursor-pointer rounded-full focus:outline-none', className)}
|
|
37
|
+
{...props}
|
|
38
|
+
>
|
|
39
|
+
<Avatar name={name} src={picture} size={size ?? 36} />
|
|
40
|
+
</button>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
aria-haspopup="menu"
|
|
47
|
+
aria-expanded={open}
|
|
48
|
+
className={cn(
|
|
49
|
+
// The size is an arbitrary value (the body-2 token): this list goes through
|
|
50
|
+
// cn(), and tw-merge silently drops a custom text-{size} once a
|
|
51
|
+
// text-{colour} follows it. Measured: the name rendered 16px.
|
|
52
|
+
'flex h-8 cursor-pointer items-center gap-1.5 rounded-md pl-1.5 pr-1.5 text-[14px] leading-[140%] text-text-primary transition-colors hover:bg-bg-hover',
|
|
53
|
+
open && 'bg-bg-hover',
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
<Person name={name} picture={picture} fallback={fallback} size={size ?? 22} />
|
|
59
|
+
<IconChevronDown size={14} stroke={1.5} className="shrink-0 text-text-muted" />
|
|
60
|
+
</button>
|
|
61
|
+
)
|
|
62
|
+
}
|
package/src/Property.mdx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as PropertyStories from './Property.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={PropertyStories} />
|
|
5
|
+
|
|
6
|
+
# Property
|
|
7
|
+
|
|
8
|
+
A labelled property — "Status", "Lead", and whatever else a surface
|
|
9
|
+
declares. Two layouts, one component.
|
|
10
|
+
|
|
11
|
+
<Canvas of={PropertyStories.APanel} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- **`row`** — a details panel: a 68px `caption` label column that lines
|
|
16
|
+
the values up into a column of their own. The label reads `secondary` —
|
|
17
|
+
a property label is content, not a placeholder.
|
|
18
|
+
- **`stacked`** — a rail or form-like column: the label above a
|
|
19
|
+
full-width control, in the 9px `menu` token, uppercase and
|
|
20
|
+
letter-spaced, 12px above its content.
|
|
21
|
+
- An empty value is a muted em dash — show the property, not a blank.
|
|
22
|
+
|
|
23
|
+
<Canvas of={PropertyStories.Stacked} />
|
|
24
|
+
|
|
25
|
+
## When not
|
|
26
|
+
|
|
27
|
+
- A **section title** → **SectionLabel** / **SectionHeader**. The stacked
|
|
28
|
+
label is deliberately not a SectionLabel — a field label and a section
|
|
29
|
+
title are different voices at different sizes.
|
|
30
|
+
- A labelled **input** in a form → **Field** (label, required mark).
|
|
31
|
+
|
|
32
|
+
## How
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { Property } from '@estiva-app/ui'
|
|
36
|
+
|
|
37
|
+
<Property label="Status">
|
|
38
|
+
<Chip type="success" label="Done" />
|
|
39
|
+
</Property>
|
|
40
|
+
|
|
41
|
+
<Property label="Assignee" layout="stacked">
|
|
42
|
+
<Select value={assignee} options={people} onChange={setAssignee} />
|
|
43
|
+
</Property>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Any control sits inside — a Chip, a Person, a Select, plain text (the
|
|
47
|
+
WithControls story shows the pairings).
|
|
48
|
+
|
|
49
|
+
## Props
|
|
50
|
+
|
|
51
|
+
<Controls of={PropertyStories.Row} />
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { Chip } from './Chip'
|
|
4
|
+
import { Person } from './Person'
|
|
5
|
+
import { Property } from './Property'
|
|
6
|
+
import { Select } from './Select'
|
|
7
|
+
|
|
8
|
+
/** A labelled property — the 68px label column is what lines the values up. */
|
|
9
|
+
const meta = {
|
|
10
|
+
title: 'Primitives/Property',
|
|
11
|
+
component: Property,
|
|
12
|
+
decorators: [(Story) => <div className="w-[260px]"><Story /></div>],
|
|
13
|
+
args: { label: 'Status', children: <Chip type="success" label="Done" /> },
|
|
14
|
+
argTypes: { children: { control: false } },
|
|
15
|
+
} satisfies Meta<typeof Property>
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof meta>
|
|
19
|
+
|
|
20
|
+
export const Row: Story = {}
|
|
21
|
+
|
|
22
|
+
/** A column of rows — the label column keeps the values aligned. */
|
|
23
|
+
export const APanel: Story = {
|
|
24
|
+
parameters: { controls: { disable: true } },
|
|
25
|
+
render: () => (
|
|
26
|
+
<div className="flex flex-col gap-2">
|
|
27
|
+
<Property label="Status">
|
|
28
|
+
<Chip type="success" label="Done" />
|
|
29
|
+
</Property>
|
|
30
|
+
<Property label="Lead">
|
|
31
|
+
<Person name="Ana Duarte" size={16} className="text-[12px] leading-[120%] text-text-primary" />
|
|
32
|
+
</Property>
|
|
33
|
+
<Property label="Updated">
|
|
34
|
+
<span className="text-[12px] leading-[120%] text-text-secondary">2d ago</span>
|
|
35
|
+
</Property>
|
|
36
|
+
</div>
|
|
37
|
+
),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Ship's rail layout: the 9px uppercase label above a full-width value. */
|
|
41
|
+
export const Stacked: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
label: 'Assignee',
|
|
44
|
+
layout: 'stacked',
|
|
45
|
+
children: <Person name="Ravi Mehta" size={20} className="text-[14px] leading-[140%] text-text-primary" />,
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ControlsDemo() {
|
|
50
|
+
const [status, setStatus] = useState('doing')
|
|
51
|
+
const [owner, setOwner] = useState('ana')
|
|
52
|
+
return (
|
|
53
|
+
<div className="flex flex-col gap-4">
|
|
54
|
+
<Property label="Status" layout="stacked">
|
|
55
|
+
<Select
|
|
56
|
+
ariaLabel="Status"
|
|
57
|
+
value={status}
|
|
58
|
+
onChange={setStatus}
|
|
59
|
+
options={[
|
|
60
|
+
{ value: 'todo', label: 'To do' },
|
|
61
|
+
{ value: 'doing', label: 'In progress' },
|
|
62
|
+
{ value: 'done', label: 'Done' },
|
|
63
|
+
]}
|
|
64
|
+
/>
|
|
65
|
+
</Property>
|
|
66
|
+
<Property label="Owner">
|
|
67
|
+
<Select
|
|
68
|
+
ariaLabel="Owner"
|
|
69
|
+
size="small"
|
|
70
|
+
value={owner}
|
|
71
|
+
onChange={setOwner}
|
|
72
|
+
options={[
|
|
73
|
+
{ value: 'ana', label: 'Ana Duarte' },
|
|
74
|
+
{ value: 'ravi', label: 'Ravi Mehta' },
|
|
75
|
+
]}
|
|
76
|
+
/>
|
|
77
|
+
</Property>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The property holding a control — a full-width Select under a stacked label (the rail), a small one in a row. */
|
|
83
|
+
export const WithControls: Story = {
|
|
84
|
+
parameters: { controls: { disable: true } },
|
|
85
|
+
render: () => <ControlsDemo />,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** No value: the em dash, muted — the same mark Person uses for the unnamed. */
|
|
89
|
+
export const Empty: Story = {
|
|
90
|
+
args: { label: 'Folder', children: <span className="text-[12px] leading-[120%] text-text-muted">—</span> },
|
|
91
|
+
}
|
package/src/Property.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A labelled property row — "Status", "Lead", and whatever else a page
|
|
6
|
+
* declares (2026-09-01). Both apps carried one, and they had already
|
|
7
|
+
* drifted: Peek's label was `text-xs` — a Tailwind default with the wrong
|
|
8
|
+
* line-height, not the caption token — and Ship's had grown a layout Peek's
|
|
9
|
+
* lacked. One component, both layouts, the token:
|
|
10
|
+
*
|
|
11
|
+
* - `row` — Peek's: a 68px label column that lines the values up into a
|
|
12
|
+
* column of their own; `text-secondary` rather than muted because a
|
|
13
|
+
* property label is content, not a placeholder.
|
|
14
|
+
* - `stacked` — Ship's rail: the label above a full-width control. The label
|
|
15
|
+
* is the `menu` token (9px / 115% / 500), uppercase and letter-spaced —
|
|
16
|
+
* its own voice, deliberately not SectionLabel's (unmerged, Katerina
|
|
17
|
+
* 2026-09-01). A literal string, not merged, so the token size survives.
|
|
18
|
+
*/
|
|
19
|
+
export interface PropertyProps {
|
|
20
|
+
label: string
|
|
21
|
+
layout?: 'row' | 'stacked'
|
|
22
|
+
children: ReactNode
|
|
23
|
+
className?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function Property({ label, layout = 'row', children, className }: PropertyProps) {
|
|
27
|
+
if (layout === 'stacked') {
|
|
28
|
+
return (
|
|
29
|
+
// gap-3 — the one label-above-content distance (Katerina, 2026-09-01):
|
|
30
|
+
// Peek's topic-details sections already sit at 12px, Ship's rail was
|
|
31
|
+
// 6px, and unifying means the bigger, calmer one.
|
|
32
|
+
<div className={cn('flex flex-col gap-3', className)}>
|
|
33
|
+
<span className="text-menu uppercase tracking-[0.08em] text-text-secondary">{label}</span>
|
|
34
|
+
{children}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn('flex items-center gap-2', className)}>
|
|
40
|
+
<span className="w-[68px] shrink-0 text-caption text-text-secondary">{label}</span>
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
package/src/Rail.tsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The icon rail: a 64px strip of RailItems (the shell of Peek's NavRail,
|
|
6
|
+
* 2026-09-02 — its entries and their routes stayed in the app).
|
|
7
|
+
*
|
|
8
|
+
* It has no border and no surface of its own — it stands on the app
|
|
9
|
+
* background, beside whatever card or column the app draws. Collapsing
|
|
10
|
+
* is the caller's: the rail does not know about the burger, it only gets
|
|
11
|
+
* given less room (or none) to stand in. Desktop only.
|
|
12
|
+
*/
|
|
13
|
+
export interface RailProps {
|
|
14
|
+
/** Names the navigation region for assistive tech. */
|
|
15
|
+
'aria-label'?: string
|
|
16
|
+
children: ReactNode
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function Rail({ 'aria-label': ariaLabel = 'Navigation', children, className }: RailProps) {
|
|
21
|
+
return (
|
|
22
|
+
<nav aria-label={ariaLabel} className={cn('flex w-16 shrink-0 flex-col items-start gap-2 px-2 py-3', className)}>
|
|
23
|
+
{children}
|
|
24
|
+
</nav>
|
|
25
|
+
)
|
|
26
|
+
}
|
package/src/RailItem.mdx
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as RailItemStories from './RailItem.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={RailItemStories} />
|
|
5
|
+
|
|
6
|
+
# RailItem
|
|
7
|
+
|
|
8
|
+
One tile of an icon rail: the icon in its hover tile, a 9px label under
|
|
9
|
+
it. **Rail** is its shell — the 64px strip the tiles stand in, with no
|
|
10
|
+
border and no surface of its own.
|
|
11
|
+
|
|
12
|
+
<Canvas of={RailItemStories.InTheRail} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- An app whose navigation is a narrow icon strip beside the content, with
|
|
17
|
+
a handful of top-level places.
|
|
18
|
+
- `active` fills the tile (and some themes give the icon their
|
|
19
|
+
interactive colour), plus `aria-current="page"`.
|
|
20
|
+
- The rail pairs with the **floating TopBar** (AppShell's `floating`
|
|
21
|
+
variant): the menu button in the bar's `menu` slot collapses it, the
|
|
22
|
+
app's name or mark rides in `logo` beside it. Collapsing is the
|
|
23
|
+
caller's — the rail only gets given less room (or none) to stand in.
|
|
24
|
+
|
|
25
|
+
## When not
|
|
26
|
+
|
|
27
|
+
- Entries that need readable labels or counts → **NavItem** in a
|
|
28
|
+
**Sidebar**.
|
|
29
|
+
- More places than a handful — a rail without room stops being glanceable;
|
|
30
|
+
use the Sidebar.
|
|
31
|
+
|
|
32
|
+
## How
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
import { Rail, RailItem } from '@estiva-app/ui'
|
|
36
|
+
|
|
37
|
+
<Rail>
|
|
38
|
+
<RailItem href="#/desk" label="Desk" icon={<IconNote size={16} stroke={1.5} />} active={at('desk')} />
|
|
39
|
+
<RailItem href="#/people" label="People" icon={<IconUsers size={16} stroke={1.5} />} active={at('people')} />
|
|
40
|
+
</Rail>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- Both render plain anchors and a `nav` region. A router app keeps a thin
|
|
44
|
+
wrapper per item: compute `active` from the location, intercept the
|
|
45
|
+
click, navigate.
|
|
46
|
+
- Icons are 16px at stroke 1.5.
|
|
47
|
+
|
|
48
|
+
## Props
|
|
49
|
+
|
|
50
|
+
<Controls of={RailItemStories.Default} />
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { Rail } from './Rail'
|
|
4
|
+
import { RailItem } from './RailItem'
|
|
5
|
+
|
|
6
|
+
const placeholder = <IconSquareRounded size={16} stroke={1.5} />
|
|
7
|
+
|
|
8
|
+
const meta = {
|
|
9
|
+
title: 'Frame/RailItem',
|
|
10
|
+
component: RailItem,
|
|
11
|
+
args: { label: 'Item', href: '#', active: false, icon: placeholder },
|
|
12
|
+
argTypes: { icon: { control: false } },
|
|
13
|
+
} satisfies Meta<typeof RailItem>
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof meta>
|
|
17
|
+
|
|
18
|
+
const tile = [(Story: () => React.ReactNode) => <div className="w-16">{Story()}</div>]
|
|
19
|
+
|
|
20
|
+
export const Default: Story = { decorators: tile }
|
|
21
|
+
|
|
22
|
+
/** Active fills the tile — and some themes give the icon their interactive colour. */
|
|
23
|
+
export const Active: Story = { args: { active: true }, decorators: tile }
|
|
24
|
+
|
|
25
|
+
/** The Rail shell as it stands in a frame: on the left, full height, tiles at the top. */
|
|
26
|
+
export const InTheRail: Story = {
|
|
27
|
+
parameters: { layout: 'fullscreen', controls: { disable: true } },
|
|
28
|
+
render: () => (
|
|
29
|
+
<div className="flex h-screen bg-bg-base">
|
|
30
|
+
<Rail>
|
|
31
|
+
<RailItem href="#" label="Item" icon={placeholder} active />
|
|
32
|
+
<RailItem href="#" label="Item" icon={placeholder} />
|
|
33
|
+
<RailItem href="#" label="Item" icon={placeholder} />
|
|
34
|
+
</Rail>
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
}
|
package/src/RailItem.tsx
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One tile of an icon rail: the icon in its hover tile, a 9px label
|
|
6
|
+
* under it (Peek's NavItem, 2026-09-02, verbatim — minus the router).
|
|
7
|
+
*
|
|
8
|
+
* The original computed its own active state from the route and rendered
|
|
9
|
+
* a router link; neither belongs in a shared component. This one takes
|
|
10
|
+
* `active` and renders an anchor — a router app keeps a thin wrapper
|
|
11
|
+
* that does its own matching and intercepts the click.
|
|
12
|
+
*
|
|
13
|
+
* Active is `bg-selected` on the tile — some themes also give the icon
|
|
14
|
+
* their interactive colour.
|
|
15
|
+
*/
|
|
16
|
+
export interface RailItemProps extends Omit<ComponentPropsWithoutRef<'a'>, 'href'> {
|
|
17
|
+
href: string
|
|
18
|
+
/** 16px, stroke 1.5. */
|
|
19
|
+
icon: ReactNode
|
|
20
|
+
label: string
|
|
21
|
+
active?: boolean
|
|
22
|
+
className?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function RailItem({ href, icon, label, active = false, className, ...props }: RailItemProps) {
|
|
26
|
+
return (
|
|
27
|
+
<a
|
|
28
|
+
href={href}
|
|
29
|
+
aria-current={active ? 'page' : undefined}
|
|
30
|
+
className={cn('group flex w-full shrink-0 flex-col items-center gap-0.5 px-2 py-0.5', className)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
<div
|
|
34
|
+
className={cn(
|
|
35
|
+
'flex items-center justify-center rounded-lg p-2 transition-colors',
|
|
36
|
+
active ? 'bg-bg-selected' : 'group-hover:bg-bg-hover',
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<span
|
|
40
|
+
className={cn(
|
|
41
|
+
'flex items-center transition-colors',
|
|
42
|
+
active ? 'text-text-primary signal:text-[color:var(--text-interactive)]' : 'text-text-secondary',
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{icon}
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
<span className={cn('text-center text-[9px] font-medium leading-[115%]', active ? 'text-text-primary' : 'text-text-secondary')}>
|
|
49
|
+
{label}
|
|
50
|
+
</span>
|
|
51
|
+
</a>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as SearchInputStories from './SearchInput.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={SearchInputStories} />
|
|
5
|
+
|
|
6
|
+
# SearchInput
|
|
7
|
+
|
|
8
|
+
An inset field with a hairline border that strengthens on focus, and an
|
|
9
|
+
optional keyboard hint at the right edge.
|
|
10
|
+
|
|
11
|
+
<Canvas of={SearchInputStories.WithShortcut} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- Filtering a list, live, above the list it filters.
|
|
16
|
+
- As a **launcher affordance**: keep the input `pointer-events-none` and
|
|
17
|
+
open your command surface from a click on the surround — the component
|
|
18
|
+
is the same either way, and `shortcut` shows the way in ("⌘ K").
|
|
19
|
+
|
|
20
|
+
## When not
|
|
21
|
+
|
|
22
|
+
- General text entry → **TextInput**.
|
|
23
|
+
- Picking entries from a directory → **ChipInput** or **Select**.
|
|
24
|
+
|
|
25
|
+
## How
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { SearchInput } from '@estiva-app/ui'
|
|
29
|
+
|
|
30
|
+
<SearchInput value={query} onChange={(e) => setQuery(e.target.value)} shortcut="⌘ K" />
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- The default placeholder is "Search…" — override it with your own.
|
|
34
|
+
- Every native input prop passes through; give it an `aria-label`
|
|
35
|
+
("Search") since the placeholder is not a name.
|
|
36
|
+
|
|
37
|
+
## Props
|
|
38
|
+
|
|
39
|
+
<Controls of={SearchInputStories.Default} />
|