@estiva-app/ui 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/dist/Avatar.d.ts +16 -0
- package/dist/Avatar.d.ts.map +1 -0
- package/dist/Button.d.ts +28 -0
- package/dist/Button.d.ts.map +1 -0
- package/dist/Chip.d.ts +21 -0
- package/dist/Chip.d.ts.map +1 -0
- package/dist/DialogShell.d.ts +19 -0
- package/dist/DialogShell.d.ts.map +1 -0
- package/dist/Divider.d.ts +12 -0
- package/dist/Divider.d.ts.map +1 -0
- package/dist/EmptyState.d.ts +14 -0
- package/dist/EmptyState.d.ts.map +1 -0
- package/dist/Field.d.ts +13 -0
- package/dist/Field.d.ts.map +1 -0
- package/dist/IconButton.d.ts +17 -0
- package/dist/IconButton.d.ts.map +1 -0
- package/dist/Select.d.ts +30 -0
- package/dist/Select.d.ts.map +1 -0
- package/dist/Skeleton.d.ts +24 -0
- package/dist/Skeleton.d.ts.map +1 -0
- package/dist/TextInput.d.ts +11 -0
- package/dist/TextInput.d.ts.map +1 -0
- package/dist/Textarea.d.ts +8 -0
- package/dist/Textarea.d.ts.map +1 -0
- package/dist/Tooltip.d.ts +22 -0
- package/dist/Tooltip.d.ts.map +1 -0
- package/dist/cn.d.ts +10 -0
- package/dist/cn.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +486 -0
- package/dist/index.js.map +7 -0
- package/package.json +74 -0
- package/src/Avatar.stories.tsx +55 -0
- package/src/Avatar.tsx +81 -0
- package/src/Button.stories.tsx +53 -0
- package/src/Button.tsx +67 -0
- package/src/Chip.stories.tsx +37 -0
- package/src/Chip.tsx +43 -0
- package/src/DialogShell.stories.tsx +97 -0
- package/src/DialogShell.tsx +65 -0
- package/src/Divider.stories.tsx +44 -0
- package/src/Divider.tsx +22 -0
- package/src/EmptyState.stories.tsx +18 -0
- package/src/EmptyState.tsx +24 -0
- package/src/Field.stories.tsx +22 -0
- package/src/Field.tsx +24 -0
- package/src/IconButton.stories.tsx +44 -0
- package/src/IconButton.tsx +60 -0
- package/src/Select.stories.tsx +107 -0
- package/src/Select.tsx +195 -0
- package/src/Skeleton.stories.tsx +33 -0
- package/src/Skeleton.tsx +38 -0
- package/src/TextInput.stories.tsx +18 -0
- package/src/TextInput.tsx +29 -0
- package/src/Textarea.stories.tsx +17 -0
- package/src/Textarea.tsx +25 -0
- package/src/Tooltip.stories.tsx +47 -0
- package/src/Tooltip.tsx +70 -0
- package/src/cn.ts +13 -0
- package/src/index.ts +25 -0
- package/tailwind-preset.js +162 -0
- package/tokens.css +225 -0
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@estiva-app/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Estiva's design tokens (the contract) and a small set of primitives (a convenience) for every Estiva app.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"src",
|
|
10
|
+
"tailwind-preset.js",
|
|
11
|
+
"tokens.css",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./tailwind-preset": "./tailwind-preset.js",
|
|
21
|
+
"./tokens.css": "./tokens.css",
|
|
22
|
+
"./package.json": "./package.json"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "node build.mjs && tsc -p tsconfig.build.json",
|
|
26
|
+
"storybook": "storybook dev -p 6008 --no-open",
|
|
27
|
+
"build-storybook": "storybook build",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"prepublishOnly": "npm run build"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@tabler/icons-react": "^3.41.1",
|
|
35
|
+
"react": "^19.0.0",
|
|
36
|
+
"react-dom": "^19.0.0",
|
|
37
|
+
"tailwindcss": "^3.4.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@storybook/addon-docs": "^10.5.10",
|
|
41
|
+
"@storybook/react-vite": "^10.5.10",
|
|
42
|
+
"@tabler/icons-react": "^3.41.1",
|
|
43
|
+
"@types/node": "^24.12.0",
|
|
44
|
+
"@types/react": "^19.2.14",
|
|
45
|
+
"@types/react-dom": "^19.2.3",
|
|
46
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
47
|
+
"autoprefixer": "^10.4.27",
|
|
48
|
+
"esbuild": "^0.28.0",
|
|
49
|
+
"postcss": "^8.5.8",
|
|
50
|
+
"react": "^19.2.4",
|
|
51
|
+
"react-dom": "^19.2.4",
|
|
52
|
+
"storybook": "^10.5.10",
|
|
53
|
+
"tailwindcss": "^3.4.19",
|
|
54
|
+
"typescript": "~5.9.3",
|
|
55
|
+
"vite": "^8.0.1",
|
|
56
|
+
"vitest": "^4.1.11"
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"clsx": "^2.1.1",
|
|
60
|
+
"tailwind-merge": "^3.5.0"
|
|
61
|
+
},
|
|
62
|
+
"main": "./dist/index.js",
|
|
63
|
+
"types": "./dist/index.d.ts",
|
|
64
|
+
"sideEffects": [
|
|
65
|
+
"*.css"
|
|
66
|
+
],
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
|
+
},
|
|
70
|
+
"repository": {
|
|
71
|
+
"type": "git",
|
|
72
|
+
"url": "git+https://github.com/estiva-app/estiva-ui.git"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Avatar } from './Avatar'
|
|
3
|
+
|
|
4
|
+
/** A picture, or initials on the person's own colour, or a silhouette. Nothing here comes from an app. */
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Primitives/Avatar',
|
|
7
|
+
component: Avatar,
|
|
8
|
+
args: { name: 'Ana Duarte', size: 36 },
|
|
9
|
+
} satisfies Meta<typeof Avatar>
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj<typeof meta>
|
|
13
|
+
|
|
14
|
+
/** Two words, two letters, on a colour chosen from the name. */
|
|
15
|
+
export const Initials: Story = {}
|
|
16
|
+
|
|
17
|
+
/** No name, no picture: the silhouette. Never a key, never a `?`. */
|
|
18
|
+
export const Unnamed: Story = { args: { name: undefined } }
|
|
19
|
+
|
|
20
|
+
/** A picture — here an inline SVG, so the story needs nothing from anywhere. */
|
|
21
|
+
export const WithPicture: Story = {
|
|
22
|
+
args: {
|
|
23
|
+
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="%2346c08a"/><circle cx="5" cy="4" r="2" fill="%23fff"/></svg>',
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A picture that fails to load falls back to the initials, not a hole. */
|
|
28
|
+
export const BrokenPicture: Story = { args: { src: 'http://localhost:1/nothing-here.png' } }
|
|
29
|
+
|
|
30
|
+
/** Peek's size scale, plus the smaller ones Ship uses in rows and menus. */
|
|
31
|
+
export const Sizes: Story = {
|
|
32
|
+
parameters: { controls: { disable: true } },
|
|
33
|
+
render: (args) => (
|
|
34
|
+
<div className="flex items-end gap-3">
|
|
35
|
+
{[16, 20, 22, 24, 28, 32, 36].map((size) => (
|
|
36
|
+
<div key={size} className="flex flex-col items-center gap-1">
|
|
37
|
+
<Avatar {...args} size={size} />
|
|
38
|
+
<span className="text-[12px] leading-[120%] text-text-muted">{size}</span>
|
|
39
|
+
</div>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The same eight people always get the same eight colours. */
|
|
46
|
+
export const Palette: Story = {
|
|
47
|
+
parameters: { controls: { disable: true } },
|
|
48
|
+
render: () => (
|
|
49
|
+
<div className="flex gap-2">
|
|
50
|
+
{['Ana Duarte', 'Ravi Mehta', 'Mara Sato', 'Jonas Weber', 'Lin Chen', 'Sofia Rossi', 'Tomás Silva', 'Aiko Tanaka'].map((name) => (
|
|
51
|
+
<Avatar key={name} name={name} />
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
),
|
|
55
|
+
}
|
package/src/Avatar.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { IconUserFilled } from '@tabler/icons-react'
|
|
3
|
+
import { cn } from './cn'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A person's face: their picture, or their initials on a colour that is
|
|
7
|
+
* theirs, or — when there is no name to take initials from — a silhouette.
|
|
8
|
+
*
|
|
9
|
+
* Peek's Avatar (2026-08-28), made self-contained: Peek's version found the
|
|
10
|
+
* picture itself, by name, from Peek's data (an upload in Convex, else the
|
|
11
|
+
* demo portrait), and switched the initials on only under its Signal theme.
|
|
12
|
+
* Neither belongs in a shared component, so this one takes the picture URL
|
|
13
|
+
* from its caller and draws the initials in every theme. Peek keeps a
|
|
14
|
+
* three-line wrapper that resolves the picture; Ship passes the relay's
|
|
15
|
+
* `kind:0` picture straight in.
|
|
16
|
+
*
|
|
17
|
+
* Initials come from a *name*, never from a key: a component that accepted a
|
|
18
|
+
* pubkey would eventually show one (Ship's ruling, 2026-08-24). An unnamed
|
|
19
|
+
* person is the silhouette — `?` on a bright gradient read as an error badge
|
|
20
|
+
* rather than a person (Peek, FEE-1).
|
|
21
|
+
*
|
|
22
|
+
* The hue is a fallback palette, not a token: it is per person, chosen
|
|
23
|
+
* from the name so the same person is the same colour everywhere, and a
|
|
24
|
+
* theme has no say in it. Peek's eight, verbatim.
|
|
25
|
+
*/
|
|
26
|
+
const HUES = ['#56c8ff', '#ff8f6b', '#4ade8c', '#b18cff', '#ffc94d', '#ff7eb0', '#7ea8ff', '#5fdfd6']
|
|
27
|
+
|
|
28
|
+
export const hueFor = (key: string) => {
|
|
29
|
+
let h = 0
|
|
30
|
+
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0
|
|
31
|
+
return HUES[Math.abs(h) % HUES.length]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Peek's rule: the first letter of the first two words. */
|
|
35
|
+
export const initialsFor = (name: string) =>
|
|
36
|
+
name
|
|
37
|
+
.split(' ')
|
|
38
|
+
.map((w) => w[0])
|
|
39
|
+
.slice(0, 2)
|
|
40
|
+
.join('')
|
|
41
|
+
.toUpperCase()
|
|
42
|
+
|
|
43
|
+
export interface AvatarProps {
|
|
44
|
+
/** The picture URL, resolved by the caller. A picture that fails to load falls back to the initials. */
|
|
45
|
+
src?: string
|
|
46
|
+
/** The person's name — where the initials and the colour come from. */
|
|
47
|
+
name?: string
|
|
48
|
+
/** Alt text for the picture; the name when absent. Also the initials' source when there is no name. */
|
|
49
|
+
alt?: string
|
|
50
|
+
/** Pixels. Peek's scale: 16 · 24 · 32 · 36 (default). */
|
|
51
|
+
size?: number
|
|
52
|
+
className?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function Avatar({ src, name, alt = '', size = 36, className }: AvatarProps) {
|
|
56
|
+
const [broken, setBroken] = useState(false)
|
|
57
|
+
const label = name || alt
|
|
58
|
+
const picture = src && !broken ? src : undefined
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn('rounded-sm overflow-hidden shrink-0 bg-bg-inset', className)} style={{ width: size, height: size }}>
|
|
61
|
+
{picture ? (
|
|
62
|
+
<img src={picture} alt={alt || name || ''} className="w-full h-full object-cover" onError={() => setBroken(true)} />
|
|
63
|
+
) : label ? (
|
|
64
|
+
<div
|
|
65
|
+
className="w-full h-full flex items-center justify-center font-semibold"
|
|
66
|
+
style={{
|
|
67
|
+
color: '#08121c',
|
|
68
|
+
fontSize: Math.round(size * 0.36),
|
|
69
|
+
background: `linear-gradient(160deg, color-mix(in srgb, ${hueFor(label)} 92%, #fff) 0%, color-mix(in srgb, ${hueFor(label)} 70%, #0b0d11) 100%)`,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{initialsFor(label)}
|
|
73
|
+
</div>
|
|
74
|
+
) : (
|
|
75
|
+
<div className="w-full h-full bg-accent-muted flex items-center justify-center text-text-muted">
|
|
76
|
+
<IconUserFilled size={Math.round(size * 0.5)} />
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconPlus } from '@tabler/icons-react'
|
|
3
|
+
import { Button } from './Button'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Primitives/Button',
|
|
7
|
+
component: Button,
|
|
8
|
+
args: { children: 'Button', variant: 'muted', size: 'default', disabled: false },
|
|
9
|
+
argTypes: {
|
|
10
|
+
variant: { control: 'inline-radio', options: ['primary', 'outlined', 'muted', 'destructive'] },
|
|
11
|
+
size: { control: 'inline-radio', options: ['default', 'small'] },
|
|
12
|
+
leadingIcon: { control: false },
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof Button>
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj<typeof meta>
|
|
18
|
+
|
|
19
|
+
export const Primary: Story = { args: { variant: 'primary' } }
|
|
20
|
+
export const Outlined: Story = { args: { variant: 'outlined' } }
|
|
21
|
+
export const Muted: Story = { args: { variant: 'muted' } }
|
|
22
|
+
/** The muted button in the error colour — for "Delete …" and its kind. Ship's addition. */
|
|
23
|
+
export const Destructive: Story = { args: { variant: 'destructive', children: 'Delete project' } }
|
|
24
|
+
export const Small: Story = { args: { variant: 'primary', size: 'small' } }
|
|
25
|
+
export const WithLeadingIcon: Story = { args: { variant: 'primary', leadingIcon: <IconPlus stroke={1.5} className="size-4" /> } }
|
|
26
|
+
/** Disabled is for a control that is momentarily unavailable — say why, with a tooltip. A control someone may never use is absent, not disabled. */
|
|
27
|
+
export const Disabled: Story = { args: { variant: 'primary', disabled: true } }
|
|
28
|
+
|
|
29
|
+
/** Every variant × size × icon × disabled combination on one canvas. */
|
|
30
|
+
export const AllVariants: Story = {
|
|
31
|
+
parameters: { controls: { disable: true } },
|
|
32
|
+
render: () => (
|
|
33
|
+
<div className="flex flex-col gap-4">
|
|
34
|
+
{(['primary', 'outlined', 'muted', 'destructive'] as const).map((variant) => (
|
|
35
|
+
<div key={variant} className="flex items-center gap-3">
|
|
36
|
+
{(['default', 'small'] as const).map((size) => (
|
|
37
|
+
<div key={size} className="flex items-center gap-2">
|
|
38
|
+
<Button variant={variant} size={size}>
|
|
39
|
+
Button
|
|
40
|
+
</Button>
|
|
41
|
+
<Button variant={variant} size={size} leadingIcon={<IconPlus stroke={1.5} className={size === 'small' ? 'size-3.5' : 'size-4'} />}>
|
|
42
|
+
Button
|
|
43
|
+
</Button>
|
|
44
|
+
<Button variant={variant} size={size} disabled>
|
|
45
|
+
Button
|
|
46
|
+
</Button>
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
}
|
package/src/Button.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Peek's Button (2026-08-28), verbatim, plus what Ship added and Peek should
|
|
6
|
+
* adopt: a `destructive` variant — the muted button in the error colour, for
|
|
7
|
+
* "Delete project" and its kind; a destructive action is a button like any
|
|
8
|
+
* other, not a dotted link — and `type="button"` by default, so a button
|
|
9
|
+
* inside a form submits it only when asked to.
|
|
10
|
+
*
|
|
11
|
+
* Three variants and two sizes: 32px default / 24px small, 6px radius, 500
|
|
12
|
+
* weight. Sizes are spelled as arbitrary values for the reason the README
|
|
13
|
+
* records: tailwind-merge drops a custom `text-{size}` that is followed by a
|
|
14
|
+
* `text-{colour}`.
|
|
15
|
+
*
|
|
16
|
+
* The primary reads in `text-inverse` on the accent — a theme decides what
|
|
17
|
+
* that is (dark on Peek's light accents, light on Ship's dark one). Under
|
|
18
|
+
* Signal it is also semibold, as Peek has it.
|
|
19
|
+
*/
|
|
20
|
+
export type ButtonVariant = 'primary' | 'outlined' | 'muted' | 'destructive'
|
|
21
|
+
export type ButtonSize = 'default' | 'small'
|
|
22
|
+
|
|
23
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
24
|
+
variant?: ButtonVariant
|
|
25
|
+
size?: ButtonSize
|
|
26
|
+
/** 16px, stroke 1.5 on the default size; 14px on small. */
|
|
27
|
+
leadingIcon?: ReactNode
|
|
28
|
+
children: ReactNode
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function Button({
|
|
32
|
+
variant = 'muted',
|
|
33
|
+
size = 'default',
|
|
34
|
+
leadingIcon,
|
|
35
|
+
className,
|
|
36
|
+
children,
|
|
37
|
+
disabled,
|
|
38
|
+
type = 'button',
|
|
39
|
+
...props
|
|
40
|
+
}: ButtonProps) {
|
|
41
|
+
const hasLeadingIcon = !!leadingIcon
|
|
42
|
+
return (
|
|
43
|
+
<button
|
|
44
|
+
type={type}
|
|
45
|
+
className={cn(
|
|
46
|
+
'inline-flex items-center justify-center gap-1 rounded-md transition-colors font-sans font-medium',
|
|
47
|
+
size === 'default' && 'h-8 text-[14px] leading-[14px]',
|
|
48
|
+
size === 'small' && 'h-6 text-[12px] leading-[12px]',
|
|
49
|
+
// Extra right padding beside a leading icon, for optical balance.
|
|
50
|
+
size === 'default' && (hasLeadingIcon ? 'pl-2 pr-3' : 'px-2'),
|
|
51
|
+
size === 'small' && (hasLeadingIcon ? 'pl-1.5 pr-2' : 'px-1.5'),
|
|
52
|
+
!disabled && variant === 'primary' && 'bg-accent-primary hover:bg-accent-hover text-text-inverse cursor-pointer signal:font-semibold',
|
|
53
|
+
!disabled && variant === 'outlined' && 'border border-border-default hover:bg-bg-hover text-text-primary cursor-pointer',
|
|
54
|
+
!disabled && variant === 'muted' && 'hover:bg-bg-hover text-text-primary cursor-pointer',
|
|
55
|
+
!disabled && variant === 'destructive' && 'hover:bg-error-muted text-error-default cursor-pointer',
|
|
56
|
+
disabled && 'bg-bg-disabled text-text-disabled pointer-events-none',
|
|
57
|
+
disabled && variant === 'outlined' && 'border border-border-default',
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
disabled={disabled}
|
|
61
|
+
{...props}
|
|
62
|
+
>
|
|
63
|
+
{leadingIcon}
|
|
64
|
+
{children}
|
|
65
|
+
</button>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconArrowUp, IconMessage2 } from '@tabler/icons-react'
|
|
3
|
+
import { Chip } from './Chip'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Primitives/Chip',
|
|
7
|
+
component: Chip,
|
|
8
|
+
args: { type: 'neutral', label: 'Label' },
|
|
9
|
+
argTypes: {
|
|
10
|
+
type: { control: 'inline-radio', options: ['neutral', 'brand', 'info', 'warning', 'success', 'error'] },
|
|
11
|
+
leadingIcon: { control: false },
|
|
12
|
+
trailingIcon: { control: false },
|
|
13
|
+
},
|
|
14
|
+
} satisfies Meta<typeof Chip>
|
|
15
|
+
|
|
16
|
+
export default meta
|
|
17
|
+
type Story = StoryObj<typeof meta>
|
|
18
|
+
|
|
19
|
+
export const Neutral: Story = {}
|
|
20
|
+
/** A count in the brand colour — "1 new". */
|
|
21
|
+
export const Brand: Story = { args: { type: 'brand', label: '1 new' } }
|
|
22
|
+
export const WithLeadingIcon: Story = { args: { type: 'info', label: 'Replies', leadingIcon: <IconMessage2 className="size-3" stroke={1.5} /> } }
|
|
23
|
+
export const WithTrailingIcon: Story = { args: { type: 'success', label: 'Sorted', trailingIcon: <IconArrowUp className="size-3" stroke={1.5} /> } }
|
|
24
|
+
/** A count alone. */
|
|
25
|
+
export const CountOnly: Story = { args: { type: 'neutral', label: '2' } }
|
|
26
|
+
|
|
27
|
+
/** All six colour types. */
|
|
28
|
+
export const AllTypes: Story = {
|
|
29
|
+
parameters: { controls: { disable: true } },
|
|
30
|
+
render: () => (
|
|
31
|
+
<div className="flex items-center gap-2">
|
|
32
|
+
{(['neutral', 'brand', 'info', 'warning', 'success', 'error'] as const).map((type) => (
|
|
33
|
+
<Chip key={type} type={type} label={type} />
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
),
|
|
37
|
+
}
|
package/src/Chip.tsx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Peek's Chip (2026-08-28), verbatim: a 20px pill in one of six colour
|
|
6
|
+
* types, with room for a 12px icon either side. Under Signal the label is
|
|
7
|
+
* mono and the coloured types carry a hairline, as Peek has it. The icon
|
|
8
|
+
* slots centre their icon (Ship's addition — an icon beside text otherwise
|
|
9
|
+
* rides on the baseline).
|
|
10
|
+
*
|
|
11
|
+
* The label is the `chip` type token, 11px / 500; it is a plain class here,
|
|
12
|
+
* never merged, so tailwind-merge cannot drop it.
|
|
13
|
+
*/
|
|
14
|
+
export type ChipType = 'neutral' | 'brand' | 'info' | 'warning' | 'success' | 'error'
|
|
15
|
+
|
|
16
|
+
export interface ChipProps {
|
|
17
|
+
type?: ChipType
|
|
18
|
+
label?: string
|
|
19
|
+
leadingIcon?: ReactNode
|
|
20
|
+
trailingIcon?: ReactNode
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const typeStyles: Record<ChipType, string> = {
|
|
25
|
+
neutral: 'bg-bg-inset text-text-primary',
|
|
26
|
+
brand: 'bg-accent-muted text-accent-primary signal:border signal:border-[rgba(86,200,255,0.3)]',
|
|
27
|
+
info: 'bg-info-muted text-info-default signal:border signal:border-[rgba(86,200,255,0.3)]',
|
|
28
|
+
warning: 'bg-warning-muted text-warning-default signal:border signal:border-[rgba(255,176,32,0.3)] signal:shadow-[shadow:0_0_5px_rgba(255,176,32,0.4)]',
|
|
29
|
+
success: 'bg-success-muted text-success-default signal:border signal:border-[rgba(63,222,140,0.3)]',
|
|
30
|
+
error: 'bg-error-muted text-error-default signal:border signal:border-[rgba(255,107,107,0.3)]',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Chip({ type = 'neutral', label, leadingIcon, trailingIcon, className }: ChipProps) {
|
|
34
|
+
return (
|
|
35
|
+
<div className={cn('inline-flex items-center justify-center gap-1.5 rounded-full max-h-[20px] min-w-[16px] px-2 py-1', typeStyles[type], className)}>
|
|
36
|
+
{leadingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{leadingIcon}</span>}
|
|
37
|
+
{label && (
|
|
38
|
+
<span className="text-chip whitespace-nowrap signal:font-mono signal:text-[10px] signal:font-semibold signal:tracking-[0.02em] signal:tabular-nums">{label}</span>
|
|
39
|
+
)}
|
|
40
|
+
{trailingIcon && <span className="flex size-3 shrink-0 items-center justify-center">{trailingIcon}</span>}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Button } from './Button'
|
|
4
|
+
import { DialogShell } from './DialogShell'
|
|
5
|
+
import { Field } from './Field'
|
|
6
|
+
import { TextInput } from './TextInput'
|
|
7
|
+
import { Textarea } from './Textarea'
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
title: 'Overlays/DialogShell',
|
|
11
|
+
component: DialogShell,
|
|
12
|
+
parameters: {
|
|
13
|
+
layout: 'fullscreen',
|
|
14
|
+
// Portals a fixed overlay to document.body — render in an iframe on the
|
|
15
|
+
// Docs page so it doesn't escape over the docs content.
|
|
16
|
+
docs: { story: { inline: false, height: '600px' } },
|
|
17
|
+
},
|
|
18
|
+
argTypes: { children: { control: false }, footer: { control: false } },
|
|
19
|
+
args: { title: 'Dialog title', onClose: () => {} },
|
|
20
|
+
} satisfies Meta<typeof DialogShell>
|
|
21
|
+
|
|
22
|
+
export default meta
|
|
23
|
+
type Story = StoryObj<typeof meta>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The reusable dialog scaffold. A new dialog is just the header title, a body
|
|
27
|
+
* (fields wrapped in `Field`), and a footer of buttons — the portal, backdrop,
|
|
28
|
+
* card, and chrome all come from `DialogShell`.
|
|
29
|
+
*/
|
|
30
|
+
export const Default: Story = {
|
|
31
|
+
args: {
|
|
32
|
+
bodyClassName: 'flex flex-col gap-6',
|
|
33
|
+
footer: (
|
|
34
|
+
<>
|
|
35
|
+
<Button variant="muted">Cancel</Button>
|
|
36
|
+
<Button variant="primary">Confirm</Button>
|
|
37
|
+
</>
|
|
38
|
+
),
|
|
39
|
+
children: (
|
|
40
|
+
<>
|
|
41
|
+
<Field label="Title" required>
|
|
42
|
+
<TextInput placeholder="What is this about?" />
|
|
43
|
+
</Field>
|
|
44
|
+
<Field label="Notes">
|
|
45
|
+
<Textarea placeholder="Optional notes…" className="h-[80px]" />
|
|
46
|
+
</Field>
|
|
47
|
+
</>
|
|
48
|
+
),
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A minimal confirmation dialog — single line of body, two buttons. */
|
|
53
|
+
export const Confirmation: Story = {
|
|
54
|
+
args: {
|
|
55
|
+
title: 'Delete this?',
|
|
56
|
+
footer: (
|
|
57
|
+
<>
|
|
58
|
+
<Button variant="muted">Cancel</Button>
|
|
59
|
+
<Button variant="destructive">Delete</Button>
|
|
60
|
+
</>
|
|
61
|
+
),
|
|
62
|
+
children: <p className="text-body-2 leading-[1.4] text-text-primary">This permanently removes it. It cannot be undone.</p>,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Open it, then close it with the ✕, the backdrop, or Escape. */
|
|
67
|
+
export const OpenAndClose: Story = {
|
|
68
|
+
args: { footer: null, children: null },
|
|
69
|
+
render: (args) => {
|
|
70
|
+
const [open, setOpen] = useState(false)
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex h-screen items-center justify-center">
|
|
73
|
+
<Button variant="primary" onClick={() => setOpen(true)}>
|
|
74
|
+
Open dialog
|
|
75
|
+
</Button>
|
|
76
|
+
{open && (
|
|
77
|
+
<DialogShell
|
|
78
|
+
{...args}
|
|
79
|
+
onClose={() => setOpen(false)}
|
|
80
|
+
footer={
|
|
81
|
+
<>
|
|
82
|
+
<Button variant="muted" onClick={() => setOpen(false)}>
|
|
83
|
+
Cancel
|
|
84
|
+
</Button>
|
|
85
|
+
<Button variant="primary" onClick={() => setOpen(false)}>
|
|
86
|
+
Confirm
|
|
87
|
+
</Button>
|
|
88
|
+
</>
|
|
89
|
+
}
|
|
90
|
+
>
|
|
91
|
+
<p className="text-body-2 text-text-primary">Escape closes this too.</p>
|
|
92
|
+
</DialogShell>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
)
|
|
96
|
+
},
|
|
97
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import { IconX } from '@tabler/icons-react'
|
|
4
|
+
import { cn } from './cn'
|
|
5
|
+
import { IconButton } from './IconButton'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Peek's DialogShell (2026-08-28), verbatim: the portal, the backdrop, the
|
|
9
|
+
* 502px card and its chrome — a 48px header with the title and a close
|
|
10
|
+
* button, a body, a 48px footer for the buttons. A dialog is just what goes
|
|
11
|
+
* in the three slots. Plus what Ship added: Escape closes it, and the card
|
|
12
|
+
* says what it is to assistive tech.
|
|
13
|
+
*/
|
|
14
|
+
export interface DialogShellProps {
|
|
15
|
+
title: string
|
|
16
|
+
onClose: () => void
|
|
17
|
+
footer: ReactNode
|
|
18
|
+
children: ReactNode
|
|
19
|
+
/** Extra classes on the body (e.g. `flex flex-col gap-6`, or a max height with `overflow-y-auto`). */
|
|
20
|
+
bodyClassName?: string
|
|
21
|
+
width?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function DialogShell({ title, onClose, footer, children, bodyClassName, width = 502 }: DialogShellProps) {
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const onKey = (event: KeyboardEvent) => {
|
|
27
|
+
if (event.key === 'Escape') onClose()
|
|
28
|
+
}
|
|
29
|
+
document.addEventListener('keydown', onKey)
|
|
30
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
31
|
+
}, [onClose])
|
|
32
|
+
|
|
33
|
+
return createPortal(
|
|
34
|
+
<>
|
|
35
|
+
{/* Backdrop */}
|
|
36
|
+
<div className="fixed inset-0 z-40 bg-black/50" onClick={onClose} />
|
|
37
|
+
|
|
38
|
+
{/* Dialog */}
|
|
39
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
|
40
|
+
<div
|
|
41
|
+
role="dialog"
|
|
42
|
+
aria-modal="true"
|
|
43
|
+
aria-label={title}
|
|
44
|
+
className="bg-bg-elevated border border-border-subtle rounded-lg shadow-lg pointer-events-auto flex flex-col overflow-hidden"
|
|
45
|
+
style={{ width }}
|
|
46
|
+
>
|
|
47
|
+
{/* Header */}
|
|
48
|
+
<div className="h-12 flex items-center justify-between pl-5 pr-4 border-b border-border-subtle shrink-0">
|
|
49
|
+
<span className="text-h4 text-text-primary">{title}</span>
|
|
50
|
+
<IconButton tooltip="Close" aria-label="Close" onClick={onClose}>
|
|
51
|
+
<IconX size={16} stroke={1.5} />
|
|
52
|
+
</IconButton>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
{/* Body */}
|
|
56
|
+
<div className={cn('pl-5 pr-4 py-4 border-b border-border-subtle', bodyClassName)}>{children}</div>
|
|
57
|
+
|
|
58
|
+
{/* Footer */}
|
|
59
|
+
<div className="h-12 flex items-center justify-end gap-2 pl-5 pr-4 shrink-0">{footer}</div>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</>,
|
|
63
|
+
document.body,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { Divider } from './Divider'
|
|
3
|
+
|
|
4
|
+
/** A hairline in `border-subtle`, inset 12px each side — or standing up between two columns. */
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Primitives/Divider',
|
|
7
|
+
component: Divider,
|
|
8
|
+
} satisfies Meta<typeof Divider>
|
|
9
|
+
|
|
10
|
+
export default meta
|
|
11
|
+
type Story = StoryObj<typeof meta>
|
|
12
|
+
|
|
13
|
+
/** Between two blocks on the surface. */
|
|
14
|
+
export const Default: Story = {
|
|
15
|
+
render: () => (
|
|
16
|
+
<div className="w-80 rounded-lg border border-border-default bg-bg-surface py-3">
|
|
17
|
+
<p className="px-3 pb-2 text-body-2 text-text-primary">Above the divider</p>
|
|
18
|
+
<Divider />
|
|
19
|
+
<p className="px-3 pt-2 text-body-2 text-text-primary">Below the divider</p>
|
|
20
|
+
</div>
|
|
21
|
+
),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Inside a menu — on the elevated surface, full width (`mx-0`). */
|
|
25
|
+
export const InAMenu: Story = {
|
|
26
|
+
render: () => (
|
|
27
|
+
<div className="w-72 rounded-lg border border-border-default bg-bg-elevated p-2 text-body-2 text-text-primary shadow-lg">
|
|
28
|
+
<div className="px-2 py-1.5">First action</div>
|
|
29
|
+
<Divider className="mx-0 my-2" />
|
|
30
|
+
<div className="px-2 py-1.5">Second action</div>
|
|
31
|
+
</div>
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Standing between two columns, the height of its row. Ship's addition. */
|
|
36
|
+
export const Vertical: Story = {
|
|
37
|
+
render: () => (
|
|
38
|
+
<div className="flex h-40 w-96 gap-6 text-body-2 text-text-primary">
|
|
39
|
+
<div className="flex-1">Content</div>
|
|
40
|
+
<Divider orientation="vertical" />
|
|
41
|
+
<div className="w-32">Rail</div>
|
|
42
|
+
</div>
|
|
43
|
+
),
|
|
44
|
+
}
|
package/src/Divider.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cn } from './cn'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Peek's Divider (2026-08-28): a hairline in `border-subtle`, inset 12px each
|
|
5
|
+
* side. Plus what Ship added: `orientation="vertical"` — the same hairline
|
|
6
|
+
* standing up, stretching to its row's height, no inset — and the separator
|
|
7
|
+
* role for assistive tech.
|
|
8
|
+
*/
|
|
9
|
+
export interface DividerProps {
|
|
10
|
+
orientation?: 'horizontal' | 'vertical'
|
|
11
|
+
className?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function Divider({ orientation = 'horizontal', className }: DividerProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
role="separator"
|
|
18
|
+
aria-orientation={orientation}
|
|
19
|
+
className={cn('bg-border-subtle', orientation === 'horizontal' ? 'h-px mx-3' : 'w-px self-stretch shrink-0', className)}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { IconLock } from '@tabler/icons-react'
|
|
3
|
+
import { EmptyState } from './EmptyState'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Feedback/EmptyState',
|
|
7
|
+
component: EmptyState,
|
|
8
|
+
args: { message: 'Nothing here yet.' },
|
|
9
|
+
argTypes: { icon: { control: false } },
|
|
10
|
+
} satisfies Meta<typeof EmptyState>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
/** The default icon with the caller's words. */
|
|
16
|
+
export const Default: Story = {}
|
|
17
|
+
export const LongerMessage: Story = { args: { message: 'No topics yet. Start one from any conversation.' } }
|
|
18
|
+
export const CustomIcon: Story = { args: { icon: <IconLock size={16} stroke={1.5} />, message: 'Nothing you can read here yet.' } }
|