@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.
- 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 +36 -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 +150 -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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconMenu2, IconSquareRounded } from '@tabler/icons-react'
|
|
3
|
+
import { AppShell } from './AppShell'
|
|
4
|
+
import { Banner } from './Banner'
|
|
5
|
+
import { EmptyState } from './EmptyState'
|
|
6
|
+
import { IconButton } from './IconButton'
|
|
7
|
+
import { NavItem } from './NavItem'
|
|
8
|
+
import { PersonTrigger } from './PersonTrigger'
|
|
9
|
+
import { Rail } from './Rail'
|
|
10
|
+
import { RailItem } from './RailItem'
|
|
11
|
+
import { SearchInput } from './SearchInput'
|
|
12
|
+
import { Sidebar } from './Sidebar'
|
|
13
|
+
|
|
14
|
+
const meta = {
|
|
15
|
+
title: 'Frame/AppShell',
|
|
16
|
+
component: AppShell,
|
|
17
|
+
parameters: { layout: 'fullscreen' },
|
|
18
|
+
args: { nav: null, children: null },
|
|
19
|
+
argTypes: {
|
|
20
|
+
variant: { control: 'inline-radio', options: ['solid', 'floating'] },
|
|
21
|
+
menu: { control: false },
|
|
22
|
+
logo: { control: false },
|
|
23
|
+
search: { control: false },
|
|
24
|
+
identity: { control: false },
|
|
25
|
+
banner: { control: false },
|
|
26
|
+
nav: { control: false },
|
|
27
|
+
children: { control: false },
|
|
28
|
+
},
|
|
29
|
+
decorators: [(Story) => <div className="h-screen overflow-hidden">{Story()}</div>],
|
|
30
|
+
} satisfies Meta<typeof AppShell>
|
|
31
|
+
|
|
32
|
+
export default meta
|
|
33
|
+
type Story = StoryObj<typeof meta>
|
|
34
|
+
|
|
35
|
+
const identity = <PersonTrigger name="Ana Duarte" />
|
|
36
|
+
const menuButton = (
|
|
37
|
+
<IconButton tooltip="Toggle menu" tooltipPlacement="bottom" aria-label="Toggle menu">
|
|
38
|
+
<IconMenu2 size={16} stroke={1.5} />
|
|
39
|
+
</IconButton>
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const placeholder = <IconSquareRounded size={16} stroke={1.5} />
|
|
43
|
+
|
|
44
|
+
const sidebar = (
|
|
45
|
+
<Sidebar>
|
|
46
|
+
<NavItem href="#" label="Item one" icon={placeholder} count={18} countLabel="18 open" active />
|
|
47
|
+
<NavItem href="#" label="Item two" icon={placeholder} count={5} countLabel="5 active" />
|
|
48
|
+
</Sidebar>
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const rail = (
|
|
52
|
+
<Rail>
|
|
53
|
+
<RailItem href="#" label="Item" icon={placeholder} active />
|
|
54
|
+
<RailItem href="#" label="Item" icon={placeholder} />
|
|
55
|
+
<RailItem href="#" label="Item" icon={placeholder} />
|
|
56
|
+
</Rail>
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
/** The structured frame: solid bar, sidebar, content beside it. */
|
|
60
|
+
export const Solid: Story = {
|
|
61
|
+
render: (args) => (
|
|
62
|
+
<AppShell {...args} logo="Estiva" search={<SearchInput shortcut="⌘ K" className="w-[290px]" />} identity={identity} nav={sidebar}>
|
|
63
|
+
<div className="flex h-full items-center justify-center">
|
|
64
|
+
<EmptyState message="Nothing here yet." />
|
|
65
|
+
</div>
|
|
66
|
+
</AppShell>
|
|
67
|
+
),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The banner belongs to the content area — at its top, never across the navigation. */
|
|
71
|
+
export const SolidWithBanner: Story = {
|
|
72
|
+
render: (args) => (
|
|
73
|
+
<AppShell {...args} logo="Estiva" identity={identity} nav={sidebar} banner={<Banner tone="ok">Public key copied.</Banner>}>
|
|
74
|
+
<div className="flex h-full items-center justify-center">
|
|
75
|
+
<EmptyState message="Nothing here yet." />
|
|
76
|
+
</div>
|
|
77
|
+
</AppShell>
|
|
78
|
+
),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The floating frame: the bar floats over the top edge, the rail stands on the background, and the content lives in the rounded card. */
|
|
82
|
+
export const Floating: Story = {
|
|
83
|
+
render: (args) => (
|
|
84
|
+
<AppShell
|
|
85
|
+
{...args}
|
|
86
|
+
variant="floating"
|
|
87
|
+
menu={menuButton}
|
|
88
|
+
logo="Estiva"
|
|
89
|
+
search={<SearchInput shortcut="⌘ K" className="w-[290px]" />}
|
|
90
|
+
identity={identity}
|
|
91
|
+
nav={rail}
|
|
92
|
+
>
|
|
93
|
+
<div className="flex h-full items-center justify-center">
|
|
94
|
+
<EmptyState message="Nothing here yet." />
|
|
95
|
+
</div>
|
|
96
|
+
</AppShell>
|
|
97
|
+
),
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** In the floating frame the banner draws inside the card, at its top. */
|
|
101
|
+
export const FloatingWithBanner: Story = {
|
|
102
|
+
render: (args) => (
|
|
103
|
+
<AppShell
|
|
104
|
+
{...args}
|
|
105
|
+
variant="floating"
|
|
106
|
+
menu={menuButton}
|
|
107
|
+
logo="Estiva"
|
|
108
|
+
identity={identity}
|
|
109
|
+
nav={rail}
|
|
110
|
+
banner={<Banner tone="info">A new version is available. Reload when convenient.</Banner>}
|
|
111
|
+
>
|
|
112
|
+
<div className="flex h-full items-center justify-center">
|
|
113
|
+
<EmptyState message="Nothing here yet." />
|
|
114
|
+
</div>
|
|
115
|
+
</AppShell>
|
|
116
|
+
),
|
|
117
|
+
}
|
package/src/AppShell.tsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { TopBar } from './TopBar'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The frame of an app, in the two manners that pair with the TopBar's
|
|
7
|
+
* (Katerina, 2026-09-02: the two shells go together — the solid bar with
|
|
8
|
+
* the sidebar, the floating bar with the rail):
|
|
9
|
+
*
|
|
10
|
+
* - `solid` — the structured frame: a solid TopBar, the navigation
|
|
11
|
+
* column, the content beside it, sidebar and main scrolling
|
|
12
|
+
* independently (2026-09-02, verbatim from the structured app).
|
|
13
|
+
* - `floating` — the floating frame: the TopBar floats over the top
|
|
14
|
+
* edge, the rail stands on the app background, and the content lives
|
|
15
|
+
* in the rounded card beside it (2026-09-02, the floating app's frame
|
|
16
|
+
* geometry; its collapse animation and launcher stay in the app).
|
|
17
|
+
*
|
|
18
|
+
* The banner belongs to the content area (her ruling, 2026-09-02): it
|
|
19
|
+
* renders at the top of the main column — inside the card, in the
|
|
20
|
+
* floating manner — never across the navigation.
|
|
21
|
+
*
|
|
22
|
+
* Layout only. What goes in each region is the caller's; this component
|
|
23
|
+
* has no data and no routes.
|
|
24
|
+
*/
|
|
25
|
+
export interface AppShellProps {
|
|
26
|
+
variant?: 'solid' | 'floating'
|
|
27
|
+
/** The top bar's menu button, in an app whose navigation collapses. */
|
|
28
|
+
menu?: ReactNode
|
|
29
|
+
/** The top bar's left: the app's logo mark, or its name as text. */
|
|
30
|
+
logo?: ReactNode
|
|
31
|
+
/** The top bar's centre — a search field, when there is one. */
|
|
32
|
+
search?: ReactNode
|
|
33
|
+
/** The top bar's right cluster — an `IdentityMenu`. */
|
|
34
|
+
identity?: ReactNode
|
|
35
|
+
/** A `Banner`, or nothing — drawn at the top of the content area. */
|
|
36
|
+
banner?: ReactNode
|
|
37
|
+
/** The navigation: a `Sidebar` in the solid manner, a `Rail` in the floating one. */
|
|
38
|
+
nav: ReactNode
|
|
39
|
+
children: ReactNode
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function AppShell({ variant = 'solid', menu, logo, search, identity, banner, nav, children }: AppShellProps) {
|
|
43
|
+
const bar = <TopBar variant={variant} menu={menu} logo={logo} search={search} right={identity} />
|
|
44
|
+
|
|
45
|
+
if (variant === 'floating') {
|
|
46
|
+
return (
|
|
47
|
+
<div className="relative h-full min-h-0 overflow-hidden bg-bg-base">
|
|
48
|
+
{bar}
|
|
49
|
+
<div className="flex h-full pb-4 pr-4 pt-[52px]">
|
|
50
|
+
{nav}
|
|
51
|
+
<div
|
|
52
|
+
className={cn(
|
|
53
|
+
'flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-bg-surface',
|
|
54
|
+
'signal:border signal:border-border-subtle signal:shadow-[inset_0_1px_0_rgba(255,255,255,0.035)]',
|
|
55
|
+
)}
|
|
56
|
+
>
|
|
57
|
+
{banner}
|
|
58
|
+
<main className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">{children}</main>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="flex h-full min-h-0 flex-col bg-bg-base text-text-primary">
|
|
67
|
+
{bar}
|
|
68
|
+
<div className="flex min-h-0 flex-1">
|
|
69
|
+
{nav}
|
|
70
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
71
|
+
{banner}
|
|
72
|
+
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { initialsFor } from './Avatar'
|
|
3
|
+
|
|
4
|
+
describe('initialsFor', () => {
|
|
5
|
+
it('takes the first letter of the first two words', () => {
|
|
6
|
+
expect(initialsFor('Ana Duarte')).toBe('AD')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('gives a single-word name a single letter', () => {
|
|
10
|
+
expect(initialsFor('Yael')).toBe('Y')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('never shows a symbol — a parenthetical name gets one letter', () => {
|
|
14
|
+
// The bot rendered "C(" in the members pill (Katerina, 2026-09-01).
|
|
15
|
+
expect(initialsFor('Claude (steered by Katerina Kelepouri)')).toBe('C')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('falls back to the first character when no word starts with a letter', () => {
|
|
19
|
+
expect(initialsFor('(bot)')).toBe('(')
|
|
20
|
+
})
|
|
21
|
+
})
|
package/src/Avatar.mdx
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as AvatarStories from './Avatar.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={AvatarStories} />
|
|
5
|
+
|
|
6
|
+
# Avatar
|
|
7
|
+
|
|
8
|
+
A person's face: their picture, or their initials on a colour that is
|
|
9
|
+
theirs, or — when there is no name to take initials from — a silhouette.
|
|
10
|
+
|
|
11
|
+
<Canvas of={AvatarStories.Sizes} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- Anywhere a person appears at a glance: a row, a card, a picker, a byline.
|
|
16
|
+
- The scale is 16 · 24 · 32 · 36 (default) — pick from it rather than
|
|
17
|
+
inventing sizes.
|
|
18
|
+
|
|
19
|
+
## When not
|
|
20
|
+
|
|
21
|
+
- Several people at once → **AvatarGroup**.
|
|
22
|
+
- The face beside the name → **Person**.
|
|
23
|
+
- The face as a button that opens a menu → **PersonTrigger**.
|
|
24
|
+
|
|
25
|
+
## How
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import { Avatar } from '@estiva-app/ui'
|
|
29
|
+
|
|
30
|
+
<Avatar name={person.name} src={person.picture} size={24} />
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- The caller resolves `src`. A picture that fails to load falls back to the
|
|
34
|
+
initials — nothing to handle.
|
|
35
|
+
- Initials come from a **name, never a key**: the first letter of the first
|
|
36
|
+
two words, skipping words that open with a symbol. An unnamed person is
|
|
37
|
+
the silhouette, not a `?` — a question mark reads as an error badge.
|
|
38
|
+
- The colour behind initials is per person, chosen from the name, the same
|
|
39
|
+
everywhere they appear. It is a fallback palette, not a token — themes
|
|
40
|
+
have no say in it.
|
|
41
|
+
- The corner is `rounded-sm` and stays that way. A surface that wants
|
|
42
|
+
rounder geometry wraps the Avatar concentrically — it does not override
|
|
43
|
+
the Avatar's own radius.
|
|
44
|
+
|
|
45
|
+
<Canvas of={AvatarStories.Palette} />
|
|
46
|
+
|
|
47
|
+
## Props
|
|
48
|
+
|
|
49
|
+
<Controls of={AvatarStories.Initials} />
|
package/src/Avatar.tsx
CHANGED
|
@@ -31,14 +31,17 @@ export const hueFor = (key: string) => {
|
|
|
31
31
|
return HUES[Math.abs(h) % HUES.length]
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Peek's rule, sharpened: the first letter of the first two words — counting
|
|
36
|
+
* only words that begin with a letter or digit, so "Claude (steered by
|
|
37
|
+
* Katerina Kelepouri)" shows "C", never "C(" (Katerina, 2026-09-01: a name
|
|
38
|
+
* carrying such symbols gets a single letter rather than one of them).
|
|
39
|
+
*/
|
|
40
|
+
export const initialsFor = (name: string) => {
|
|
41
|
+
const words = name.split(/\s+/).slice(0, 2).filter((w) => /^[\p{L}\p{N}]/u.test(w))
|
|
42
|
+
const initials = words.map((w) => w[0]).join('') || name.trim().charAt(0)
|
|
43
|
+
return initials.toUpperCase()
|
|
44
|
+
}
|
|
42
45
|
|
|
43
46
|
export interface AvatarProps {
|
|
44
47
|
/** The picture URL, resolved by the caller. A picture that fails to load falls back to the initials. */
|
|
@@ -47,7 +50,7 @@ export interface AvatarProps {
|
|
|
47
50
|
name?: string
|
|
48
51
|
/** Alt text for the picture; the name when absent. Also the initials' source when there is no name. */
|
|
49
52
|
alt?: string
|
|
50
|
-
/** Pixels.
|
|
53
|
+
/** Pixels. The scale: 16 · 24 · 32 · 36 (default). */
|
|
51
54
|
size?: number
|
|
52
55
|
className?: string
|
|
53
56
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as AvatarGroupStories from './AvatarGroup.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={AvatarGroupStories} />
|
|
5
|
+
|
|
6
|
+
# AvatarGroup
|
|
7
|
+
|
|
8
|
+
Up to three overlapping 24px faces with a surface-coloured ring — the
|
|
9
|
+
member stack for anywhere a group is shown at a glance.
|
|
10
|
+
|
|
11
|
+
<Canvas of={AvatarGroupStories.OverflowShowsThree} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- A compact "who's in this" — a members pill, a row's participants, a
|
|
16
|
+
card's crew. More than three members still draws three; the caller says
|
|
17
|
+
the count elsewhere if it matters.
|
|
18
|
+
|
|
19
|
+
## When not
|
|
20
|
+
|
|
21
|
+
- One person → **Avatar**.
|
|
22
|
+
- Names need to be readable → a list of **Person** rows.
|
|
23
|
+
|
|
24
|
+
## How
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { AvatarGroup } from '@estiva-app/ui'
|
|
28
|
+
|
|
29
|
+
<AvatarGroup members={people.map((p) => ({ name: p.name, picture: p.picture }))} />
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- A member is a `name` plus an optional `picture` URL, resolved by the
|
|
33
|
+
caller — exactly as Avatar takes its `src`. No picture draws initials.
|
|
34
|
+
- The ring rides on the Avatar itself (one box); don't wrap each face in a
|
|
35
|
+
clipping window — a window minus a border crops the initials.
|
|
36
|
+
|
|
37
|
+
## Props
|
|
38
|
+
|
|
39
|
+
<Controls of={AvatarGroupStories.ThreeMembers} />
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { AvatarGroup } from './AvatarGroup'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Primitives/AvatarGroup',
|
|
6
|
+
component: AvatarGroup,
|
|
7
|
+
args: {
|
|
8
|
+
members: [{ name: 'Ana Duarte' }, { name: 'Ravi Mehta' }, { name: 'Marta Silva' }],
|
|
9
|
+
},
|
|
10
|
+
} satisfies Meta<typeof AvatarGroup>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
export const ThreeMembers: Story = {}
|
|
16
|
+
export const TwoMembers: Story = {
|
|
17
|
+
args: { members: [{ name: 'Ana Duarte' }, { name: 'Ravi Mehta' }] },
|
|
18
|
+
}
|
|
19
|
+
export const One: Story = {
|
|
20
|
+
args: { members: [{ name: 'Ana Duarte' }] },
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Five members, three faces — the stack never grows past three. */
|
|
24
|
+
export const OverflowShowsThree: Story = {
|
|
25
|
+
args: {
|
|
26
|
+
members: [
|
|
27
|
+
{ name: 'Ana Duarte' },
|
|
28
|
+
{ name: 'Ravi Mehta' },
|
|
29
|
+
{ name: 'Marta Silva' },
|
|
30
|
+
{ name: 'Jonas Weber' },
|
|
31
|
+
{ name: 'Lea Novak' },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Inside a members pill with the total count — the shape a conversation
|
|
38
|
+
* header draws over the stack. The pill is the caller's; this is the group
|
|
39
|
+
* doing its job in one.
|
|
40
|
+
*/
|
|
41
|
+
export const InMembersPill: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
members: [
|
|
44
|
+
{ name: 'Ana Duarte' },
|
|
45
|
+
{ name: 'Ravi Mehta' },
|
|
46
|
+
{ name: 'Marta Silva' },
|
|
47
|
+
{ name: 'Jonas Weber' },
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
render: (args) => (
|
|
51
|
+
<div className="bg-bg-elevated border border-border-default rounded-sm flex gap-2 items-center pl-[2px] pr-2 py-[2px] w-fit">
|
|
52
|
+
<AvatarGroup {...args} />
|
|
53
|
+
<span className="text-caption text-text-secondary">{args.members.length}</span>
|
|
54
|
+
</div>
|
|
55
|
+
),
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Avatar } from './Avatar'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Up to three overlapping 24px faces with a `bg-surface` ring — Peek's member
|
|
5
|
+
* stack (2026-09-01), with one generalisation on the way in: a member is a
|
|
6
|
+
* name plus an optional picture URL, resolved by the caller, exactly as
|
|
7
|
+
* Avatar takes its `src`. Peek's version took bare names because its Avatar
|
|
8
|
+
* wrapper resolves pictures itself; a consumer without such a wrapper could
|
|
9
|
+
* never have shown one.
|
|
10
|
+
*
|
|
11
|
+
* The ring sits on the Avatar itself: the old extra wrapper was a 24px
|
|
12
|
+
* window minus a 2px border with a fixed 24px Avatar inside, so
|
|
13
|
+
* overflow-hidden clipped 4px of face — and the right edge of the initials
|
|
14
|
+
* with it, which is how "CO" rendered as "C(". One box, whole letters.
|
|
15
|
+
*/
|
|
16
|
+
export interface AvatarGroupMember {
|
|
17
|
+
name: string
|
|
18
|
+
/** Resolved by the caller. Absent draws the initials. */
|
|
19
|
+
picture?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AvatarGroupProps {
|
|
23
|
+
members: AvatarGroupMember[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function AvatarGroup({ members }: AvatarGroupProps) {
|
|
27
|
+
const visible = members.slice(0, 3)
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex items-center pr-2">
|
|
30
|
+
{visible.map((member, i) => (
|
|
31
|
+
<Avatar
|
|
32
|
+
key={i}
|
|
33
|
+
size={24}
|
|
34
|
+
name={member.name}
|
|
35
|
+
src={member.picture}
|
|
36
|
+
alt={member.name}
|
|
37
|
+
className="-mr-2 relative border-2 border-bg-surface"
|
|
38
|
+
/>
|
|
39
|
+
))}
|
|
40
|
+
</div>
|
|
41
|
+
)
|
|
42
|
+
}
|
package/src/Banner.mdx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as BannerStories from './Banner.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={BannerStories} />
|
|
5
|
+
|
|
6
|
+
# Banner
|
|
7
|
+
|
|
8
|
+
The strip under the header where the app says something happened: one
|
|
9
|
+
line, one of four tones, gone when there is nothing to say.
|
|
10
|
+
|
|
11
|
+
<Canvas of={BannerStories.AllTones} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- The outcome or state of the whole surface: "Public key copied.", the
|
|
16
|
+
reason the last read failed, a read-only notice.
|
|
17
|
+
- **`ok`** confirms, **`info`** informs, **`warning`** cautions,
|
|
18
|
+
**`error`** reports a failure — and only `error` announces itself
|
|
19
|
+
(`role="alert"`); the others are polite (`role="status"`).
|
|
20
|
+
- It belongs to the **content area** — at its top, never across the
|
|
21
|
+
navigation. AppShell's `banner` slot places it exactly there.
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- Feedback about one action, briefly → **Toast**.
|
|
26
|
+
- A standing property of one thing → **Chip** on that thing.
|
|
27
|
+
- A notice inside the content flow with its own action — that is a
|
|
28
|
+
different component (not in the package yet).
|
|
29
|
+
|
|
30
|
+
## How
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { Banner } from '@estiva-app/ui'
|
|
34
|
+
|
|
35
|
+
{notice && <Banner tone={notice.tone}>{notice.text}</Banner>}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- Render it only while there is something to say — an empty strip is not
|
|
39
|
+
a state.
|
|
40
|
+
- One line of plain text, `body-2`. If it needs a button or a second
|
|
41
|
+
line, it has outgrown this component.
|
|
42
|
+
|
|
43
|
+
## Props
|
|
44
|
+
|
|
45
|
+
<Controls of={BannerStories.Ok} />
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Banner } from './Banner'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Feedback/Banner',
|
|
6
|
+
component: Banner,
|
|
7
|
+
parameters: { layout: 'fullscreen' },
|
|
8
|
+
args: { tone: 'ok', children: 'Public key copied.' },
|
|
9
|
+
argTypes: { tone: { control: 'inline-radio', options: ['ok', 'error', 'info', 'warning'] } },
|
|
10
|
+
} satisfies Meta<typeof Banner>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
export const Ok: Story = {}
|
|
16
|
+
|
|
17
|
+
/** An error announces itself (`role="alert"`); the rest are polite. */
|
|
18
|
+
export const Error: Story = { args: { tone: 'error', children: 'The last read failed — reconnecting.' } }
|
|
19
|
+
|
|
20
|
+
export const Info: Story = { args: { tone: 'info', children: 'A new version is available. Reload when convenient.' } }
|
|
21
|
+
|
|
22
|
+
export const Warning: Story = { args: { tone: 'warning', children: 'This workspace is read-only until sign-in.' } }
|
|
23
|
+
|
|
24
|
+
/** All four tones, stacked. */
|
|
25
|
+
export const AllTones: Story = {
|
|
26
|
+
parameters: { controls: { disable: true } },
|
|
27
|
+
render: () => (
|
|
28
|
+
<div className="flex flex-col gap-2">
|
|
29
|
+
<Banner tone="ok">Public key copied.</Banner>
|
|
30
|
+
<Banner tone="info">A new version is available. Reload when convenient.</Banner>
|
|
31
|
+
<Banner tone="warning">This workspace is read-only until sign-in.</Banner>
|
|
32
|
+
<Banner tone="error">The last read failed — reconnecting.</Banner>
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
}
|
package/src/Banner.tsx
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { cn } from './cn'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The strip under the header where the app says something happened —
|
|
5
|
+
* one line, gone when there is nothing to say (Ship's Banner,
|
|
6
|
+
* 2026-09-02, verbatim; `info` and `warning` added on extraction,
|
|
7
|
+
* Katerina's ruling — the semantic set is four everywhere else).
|
|
8
|
+
*
|
|
9
|
+
* body-2, her ruling (2026-09-01): the caption this once claimed never
|
|
10
|
+
* rendered, so 14px is what it has always been and what she kept.
|
|
11
|
+
* An `error` announces itself (`role="alert"`); the other tones are
|
|
12
|
+
* polite (`role="status"`).
|
|
13
|
+
*/
|
|
14
|
+
export type BannerTone = 'ok' | 'error' | 'info' | 'warning'
|
|
15
|
+
|
|
16
|
+
export interface BannerProps {
|
|
17
|
+
tone: BannerTone
|
|
18
|
+
children: string
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TONE: Record<BannerTone, string> = {
|
|
23
|
+
ok: 'bg-success-muted text-success-default',
|
|
24
|
+
error: 'bg-error-muted text-error-default',
|
|
25
|
+
info: 'bg-info-muted text-info-default',
|
|
26
|
+
warning: 'bg-warning-muted text-warning-default',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function Banner({ tone, children, className }: BannerProps) {
|
|
30
|
+
return (
|
|
31
|
+
<div role={tone === 'error' ? 'alert' : 'status'} className={cn('px-4 py-2 text-body-2', TONE[tone], className)}>
|
|
32
|
+
{children}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as BreadcrumbStories from './Breadcrumb.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={BreadcrumbStories} />
|
|
5
|
+
|
|
6
|
+
# Breadcrumb
|
|
7
|
+
|
|
8
|
+
A trail: where you are, and the way back up. Crumbs, muted slashes
|
|
9
|
+
between them, and a tooltip on any crumb that has had to truncate.
|
|
10
|
+
|
|
11
|
+
<Canvas of={BreadcrumbStories.OnAnItem} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- The top of a detail page: parents first, the current place last.
|
|
16
|
+
- A crumb with an `href` is a link — even the last one, when a page ends
|
|
17
|
+
its trail on somewhere to go. The last item without one is "you are
|
|
18
|
+
here" (`aria-current="page"`).
|
|
19
|
+
- `mono` sets a crumb in the mono face for a ref or an id — and a mono
|
|
20
|
+
crumb **never gives up width** to a long name beside it; the name
|
|
21
|
+
truncates, the ref stays whole.
|
|
22
|
+
|
|
23
|
+
<Canvas of={BreadcrumbStories.LongName} />
|
|
24
|
+
|
|
25
|
+
## When not
|
|
26
|
+
|
|
27
|
+
- Switching between sibling views of one thing → **Tabs**.
|
|
28
|
+
- Just a page title → the `h*` type tokens; a one-item trail says
|
|
29
|
+
nothing.
|
|
30
|
+
|
|
31
|
+
## How
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
import { Breadcrumb } from '@estiva-app/ui'
|
|
35
|
+
|
|
36
|
+
<Breadcrumb
|
|
37
|
+
items={[
|
|
38
|
+
{ label: 'Documents', href: '#/documents' },
|
|
39
|
+
{ label: 'Quarterly plan', href: '#/documents/12' },
|
|
40
|
+
{ label: 'DOC-12', mono: true },
|
|
41
|
+
]}
|
|
42
|
+
/>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- Truncation is re-measured as the trail resizes; the tooltip appears and
|
|
46
|
+
disappears with the room the trail actually has. Give the trail a
|
|
47
|
+
`min-w-0` container so it can truncate at all.
|
|
48
|
+
- The separators are `aria-hidden` — the trail reads as its places.
|
|
49
|
+
|
|
50
|
+
## Props
|
|
51
|
+
|
|
52
|
+
<Controls of={BreadcrumbStories.OnAnItem} />
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Breadcrumb } from './Breadcrumb'
|
|
3
|
+
|
|
4
|
+
/** A trail of places, ending where you are — or on a mono ref. */
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Navigation/Breadcrumb',
|
|
7
|
+
component: Breadcrumb,
|
|
8
|
+
args: {
|
|
9
|
+
items: [
|
|
10
|
+
{ label: 'Documents', href: '#' },
|
|
11
|
+
{ label: 'Quarterly plan', href: '#' },
|
|
12
|
+
{ label: 'DOC-12', mono: true },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
} satisfies Meta<typeof Breadcrumb>
|
|
16
|
+
|
|
17
|
+
export default meta
|
|
18
|
+
type Story = StoryObj<typeof meta>
|
|
19
|
+
|
|
20
|
+
export const OnAnItem: Story = {}
|
|
21
|
+
|
|
22
|
+
/** An item with no parent: straight from the list to the ref. */
|
|
23
|
+
export const NoParent: Story = {
|
|
24
|
+
args: { items: [{ label: 'Documents', href: '#' }, { label: 'DOC-12', mono: true }] },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A container page: the last crumb is a plain name, where you are. */
|
|
28
|
+
export const OnAContainer: Story = {
|
|
29
|
+
args: { items: [{ label: 'Documents', href: '#' }, { label: 'Quarterly plan' }] },
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A long name truncates; the ref stays. Hover the cut-off crumb to read the whole label — a crumb that fits shows no tooltip. */
|
|
33
|
+
export const LongName: Story = {
|
|
34
|
+
decorators: [(Story) => <div className="w-[320px]"><Story /></div>],
|
|
35
|
+
args: {
|
|
36
|
+
items: [
|
|
37
|
+
{ label: 'Documents', href: '#' },
|
|
38
|
+
{ label: 'Onboarding flow for new workspaces, the whole first ten minutes', href: '#' },
|
|
39
|
+
{ label: 'ONB-12', mono: true },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
}
|