@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,106 @@
|
|
|
1
|
+
import { Fragment, useCallback, useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
import { WithTooltip } from './Tooltip'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A trail: "Documents / Quarterly plan / DOC-12". Ship's Breadcrumb
|
|
7
|
+
* (2026-09-01), which knows nothing about what the places are.
|
|
8
|
+
*
|
|
9
|
+
* An item with an `href` is a link, even when it is last — a page may end its
|
|
10
|
+
* trail on somewhere to go. The last item is where you are when it has none,
|
|
11
|
+
* and may be `mono` (a ref, an id) or plain. The separator is a slash, muted,
|
|
12
|
+
* never read aloud.
|
|
13
|
+
*
|
|
14
|
+
* A crumb that truncates shows its whole label in a tooltip on hover
|
|
15
|
+
* (Katerina, 2026-09-01); one that fits shows nothing extra. Truncation is
|
|
16
|
+
* re-measured when the trail resizes, so the tooltip appears and disappears
|
|
17
|
+
* with the room the trail actually has.
|
|
18
|
+
*/
|
|
19
|
+
export interface Crumb {
|
|
20
|
+
label: string
|
|
21
|
+
href?: string
|
|
22
|
+
/** Set the item in the mono face — a ref, an id. */
|
|
23
|
+
mono?: boolean
|
|
24
|
+
/** Quieter — a label that is not a place. */
|
|
25
|
+
muted?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BreadcrumbProps {
|
|
29
|
+
items: Crumb[]
|
|
30
|
+
className?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
|
34
|
+
const navRef = useRef<HTMLElement>(null)
|
|
35
|
+
const labelRefs = useRef<(HTMLElement | null)[]>([])
|
|
36
|
+
const [truncated, setTruncated] = useState<ReadonlySet<number>>(new Set())
|
|
37
|
+
|
|
38
|
+
const measure = useCallback(() => {
|
|
39
|
+
const next = new Set<number>()
|
|
40
|
+
labelRefs.current.forEach((el, index) => {
|
|
41
|
+
if (el && el.scrollWidth > el.clientWidth) next.add(index)
|
|
42
|
+
})
|
|
43
|
+
setTruncated((prev) => (prev.size === next.size && [...next].every((i) => prev.has(i)) ? prev : next))
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
useLayoutEffect(() => {
|
|
47
|
+
measure()
|
|
48
|
+
const nav = navRef.current
|
|
49
|
+
// jsdom has neither layout nor ResizeObserver; without this guard a
|
|
50
|
+
// consumer app cannot render a page with a trail in its tests.
|
|
51
|
+
if (!nav || typeof ResizeObserver === 'undefined') return
|
|
52
|
+
const observer = new ResizeObserver(measure)
|
|
53
|
+
observer.observe(nav)
|
|
54
|
+
return () => observer.disconnect()
|
|
55
|
+
}, [measure, items])
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<nav ref={navRef} aria-label="Breadcrumb" className={cn('flex min-w-0 items-center gap-1.5 text-[14px] leading-[140%]', className)}>
|
|
59
|
+
{items.map((item, index) => {
|
|
60
|
+
const last = index === items.length - 1
|
|
61
|
+
// The mono size is an arbitrary value (the caption token) because this
|
|
62
|
+
// string goes through cn() and a token size before a colour class is
|
|
63
|
+
// dropped (the tailwind-merge pitfall).
|
|
64
|
+
const text = cn(
|
|
65
|
+
'truncate',
|
|
66
|
+
// A mono crumb is a ref — the identity. It never gives up width to a
|
|
67
|
+
// long name beside it (the LongName story always claimed "the ref
|
|
68
|
+
// stays"; flexbox was squeezing it anyway until this line).
|
|
69
|
+
item.mono && 'shrink-0 font-mono text-[12px] leading-[120%]',
|
|
70
|
+
item.muted || (last && item.mono) ? 'text-text-muted' : last ? 'text-text-primary' : 'text-text-secondary',
|
|
71
|
+
)
|
|
72
|
+
const setLabelRef = (el: HTMLElement | null) => {
|
|
73
|
+
labelRefs.current[index] = el
|
|
74
|
+
}
|
|
75
|
+
const crumb = item.href ? (
|
|
76
|
+
<a ref={setLabelRef} href={item.href} className={cn(text, 'hover:text-text-primary')}>
|
|
77
|
+
{item.label}
|
|
78
|
+
</a>
|
|
79
|
+
) : (
|
|
80
|
+
<span ref={setLabelRef} className={text} aria-current={last ? 'page' : undefined}>
|
|
81
|
+
{item.label}
|
|
82
|
+
</span>
|
|
83
|
+
)
|
|
84
|
+
return (
|
|
85
|
+
<Fragment key={`${item.label}-${index}`}>
|
|
86
|
+
{index > 0 && (
|
|
87
|
+
<span aria-hidden="true" className="shrink-0 text-text-muted">
|
|
88
|
+
/
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
{truncated.has(index) ? (
|
|
92
|
+
// `min-w-0 shrink` undoes the wrapper's own shrink-0 — the crumb
|
|
93
|
+
// must keep truncating inside it, or wrapping it would widen the
|
|
94
|
+
// trail and the tooltip would never be needed again.
|
|
95
|
+
<WithTooltip label={item.label} wrapperClassName="min-w-0 shrink">
|
|
96
|
+
{crumb}
|
|
97
|
+
</WithTooltip>
|
|
98
|
+
) : (
|
|
99
|
+
crumb
|
|
100
|
+
)}
|
|
101
|
+
</Fragment>
|
|
102
|
+
)
|
|
103
|
+
})}
|
|
104
|
+
</nav>
|
|
105
|
+
)
|
|
106
|
+
}
|
package/src/Button.mdx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ButtonStories from './Button.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ButtonStories} />
|
|
5
|
+
|
|
6
|
+
# Button
|
|
7
|
+
|
|
8
|
+
The push-button for an action: a verb, clicked once — "Create", "Save",
|
|
9
|
+
"Delete project". Four variants on one geometry: 32px default / 24px small,
|
|
10
|
+
6px radius, medium weight.
|
|
11
|
+
|
|
12
|
+
<Canvas of={ButtonStories.AllVariants} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- **`primary`** — the one action the surface exists for. At most one per
|
|
17
|
+
surface. It reads in `text-inverse` on the accent; the theme decides what
|
|
18
|
+
both of those are.
|
|
19
|
+
- **`outlined`** — a real alternative standing beside the primary ("Cancel"
|
|
20
|
+
next to "Save"), or a lone action that needs an edge to be found at all.
|
|
21
|
+
- **`muted`** — the default, and most buttons. Anything that should not
|
|
22
|
+
compete for attention: dialog dismissals, secondary page actions.
|
|
23
|
+
- **`destructive`** — "Delete …" and its kind: the muted button in the
|
|
24
|
+
error colour. A destructive action is a button like any other, not a
|
|
25
|
+
dotted link — it is guarded by a ConfirmDialog, not by being hard to see.
|
|
26
|
+
|
|
27
|
+
## When not
|
|
28
|
+
|
|
29
|
+
- An icon with no label → **IconButton**.
|
|
30
|
+
- Going somewhere rather than doing something → **Tabs**, **Breadcrumb**,
|
|
31
|
+
or a plain link.
|
|
32
|
+
- Choosing a value → **Select**. Acting from a popover list → **MenuItem**.
|
|
33
|
+
- A control the person may never be allowed to use is **absent, not
|
|
34
|
+
disabled**. `disabled` is for *momentarily* unavailable — and it says why,
|
|
35
|
+
with a Tooltip ("Available after sign-in").
|
|
36
|
+
|
|
37
|
+
## How
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { Button } from '@estiva-app/ui'
|
|
41
|
+
import { IconPlus } from '@tabler/icons-react'
|
|
42
|
+
|
|
43
|
+
<Button variant="primary" leadingIcon={<IconPlus stroke={1.5} className="size-4" />} onClick={create}>
|
|
44
|
+
New document
|
|
45
|
+
</Button>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `leadingIcon` is 16px at stroke 1.5 (14px, `size-3.5`, on `small`); the
|
|
49
|
+
button balances its own padding around it.
|
|
50
|
+
- `type` defaults to `"button"` — inside a form it submits only when you
|
|
51
|
+
say `type="submit"`.
|
|
52
|
+
- It is a native `<button>`, and every native prop passes through; the
|
|
53
|
+
visible label is the accessible name, so no `aria-label` is needed.
|
|
54
|
+
|
|
55
|
+
## Props
|
|
56
|
+
|
|
57
|
+
<Controls of={ButtonStories.Primary} />
|
package/src/Checkbox.mdx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as CheckboxStories from './Checkbox.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={CheckboxStories} />
|
|
5
|
+
|
|
6
|
+
# Checkbox
|
|
7
|
+
|
|
8
|
+
A 16px square that fills with the accent when checked. Controlled only —
|
|
9
|
+
the parent owns the state.
|
|
10
|
+
|
|
11
|
+
<Canvas of={CheckboxStories.Toggles} />
|
|
12
|
+
|
|
13
|
+
## When
|
|
14
|
+
|
|
15
|
+
- A yes/no the person sets: an option in a form, an include/exclude in a
|
|
16
|
+
list.
|
|
17
|
+
- Inside a clickable row: pass **no `onChange`** and the square goes inert
|
|
18
|
+
— the row's own click does the toggling, and the checkbox just shows the
|
|
19
|
+
state.
|
|
20
|
+
|
|
21
|
+
<Canvas of={CheckboxStories.InsideARow} />
|
|
22
|
+
|
|
23
|
+
## When not
|
|
24
|
+
|
|
25
|
+
- One choice out of several → **Select** (a value) or **Tabs** (a view).
|
|
26
|
+
- Inside a **Menu** — a checkbox inside a `menuitem` is invalid HTML;
|
|
27
|
+
a checkable list lives in a DialogShell.
|
|
28
|
+
|
|
29
|
+
## How
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { Checkbox } from '@estiva-app/ui'
|
|
33
|
+
|
|
34
|
+
<Checkbox checked={done} onChange={setDone} aria-label="Mark as done" />
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- It renders as a button with the `checkbox` role, so it works standalone
|
|
38
|
+
or inside clickable rows; its click never bubbles to the row.
|
|
39
|
+
- Give it an `aria-label` unless visible text right beside it names it.
|
|
40
|
+
- Space or Enter toggles it, as any button.
|
|
41
|
+
|
|
42
|
+
## Props
|
|
43
|
+
|
|
44
|
+
<Controls of={CheckboxStories.Unchecked} />
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Checkbox } from './Checkbox'
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: 'Inputs/Checkbox',
|
|
7
|
+
component: Checkbox,
|
|
8
|
+
args: { checked: false, 'aria-label': 'Example' },
|
|
9
|
+
argTypes: { onChange: { control: false } },
|
|
10
|
+
} satisfies Meta<typeof Checkbox>
|
|
11
|
+
|
|
12
|
+
export default meta
|
|
13
|
+
type Story = StoryObj<typeof meta>
|
|
14
|
+
|
|
15
|
+
export const Unchecked: Story = {}
|
|
16
|
+
export const Checked: Story = { args: { checked: true } }
|
|
17
|
+
export const Disabled: Story = { args: { disabled: true } }
|
|
18
|
+
export const DisabledChecked: Story = { args: { checked: true, disabled: true } }
|
|
19
|
+
|
|
20
|
+
/** Controlled, as always — the parent owns the state. */
|
|
21
|
+
export const Toggles: Story = {
|
|
22
|
+
parameters: { controls: { disable: true } },
|
|
23
|
+
render: () => {
|
|
24
|
+
const [checked, setChecked] = useState(false)
|
|
25
|
+
return <Checkbox checked={checked} onChange={setChecked} aria-label="Toggle me" />
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Inside a clickable row the row owns the toggle: the checkbox gets no
|
|
31
|
+
* `onChange` and goes inert, so the whole row is one target rather than two
|
|
32
|
+
* fighting ones.
|
|
33
|
+
*/
|
|
34
|
+
export const InsideARow: Story = {
|
|
35
|
+
parameters: { controls: { disable: true } },
|
|
36
|
+
render: () => {
|
|
37
|
+
const [checked, setChecked] = useState(true)
|
|
38
|
+
return (
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={() => setChecked((v) => !v)}
|
|
42
|
+
className="flex w-64 items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-bg-hover"
|
|
43
|
+
>
|
|
44
|
+
<Checkbox checked={checked} aria-label="Row state" />
|
|
45
|
+
<span className="text-[14px] leading-[1.4] text-text-primary">The row is the control</span>
|
|
46
|
+
</button>
|
|
47
|
+
)
|
|
48
|
+
},
|
|
49
|
+
}
|
package/src/Checkbox.tsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { IconCheck } from '@tabler/icons-react'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A 16px square that fills with the accent when checked — Peek's Checkbox
|
|
6
|
+
* (2026-09-01), verbatim.
|
|
7
|
+
*
|
|
8
|
+
* Controlled only; parents own the state. Rendered as a button with the
|
|
9
|
+
* checkbox role so it works standalone or inside clickable rows: a caller
|
|
10
|
+
* that toggles on the row's own click passes no `onChange`, and the square
|
|
11
|
+
* goes inert rather than stealing the click.
|
|
12
|
+
*/
|
|
13
|
+
export interface CheckboxProps {
|
|
14
|
+
checked: boolean
|
|
15
|
+
onChange?: (checked: boolean) => void
|
|
16
|
+
disabled?: boolean
|
|
17
|
+
'aria-label'?: string
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Checkbox({ checked, onChange, disabled = false, className, ...aria }: CheckboxProps) {
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
role="checkbox"
|
|
26
|
+
aria-checked={checked}
|
|
27
|
+
aria-label={aria['aria-label']}
|
|
28
|
+
disabled={disabled}
|
|
29
|
+
onClick={(e) => {
|
|
30
|
+
e.stopPropagation()
|
|
31
|
+
onChange?.(!checked)
|
|
32
|
+
}}
|
|
33
|
+
className={cn(
|
|
34
|
+
'inline-flex items-center justify-center size-4 shrink-0 rounded-[4px] border transition-colors',
|
|
35
|
+
checked
|
|
36
|
+
? 'bg-accent-primary border-accent-primary text-text-inverse'
|
|
37
|
+
: 'bg-transparent border-border-strong hover:border-text-muted',
|
|
38
|
+
disabled && 'opacity-50 pointer-events-none',
|
|
39
|
+
onChange ? 'cursor-pointer' : 'pointer-events-none',
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
{checked && <IconCheck size={12} stroke={3} />}
|
|
44
|
+
</button>
|
|
45
|
+
)
|
|
46
|
+
}
|
package/src/Chip.mdx
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ChipStories from './Chip.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ChipStories} />
|
|
5
|
+
|
|
6
|
+
# Chip
|
|
7
|
+
|
|
8
|
+
A 20px pill for a small piece of standing information — a status, a count,
|
|
9
|
+
a category — in one of six colour types. The semantic four sit on their
|
|
10
|
+
muted tints; the label is the `chip` type token, 11px / 500.
|
|
11
|
+
|
|
12
|
+
<Canvas of={ChipStories.AllTypes} />
|
|
13
|
+
|
|
14
|
+
## When
|
|
15
|
+
|
|
16
|
+
- **`neutral`** — a count or tag with no verdict attached.
|
|
17
|
+
- **`brand`** — the accent's voice: a highlight, a "you are here".
|
|
18
|
+
- **`info` / `warning` / `success` / `error`** — a state with a meaning;
|
|
19
|
+
the colour is the message, the label makes it legible.
|
|
20
|
+
|
|
21
|
+
## When not
|
|
22
|
+
|
|
23
|
+
- Something clickable → **Button** (small) or **MenuItem** — a Chip is not
|
|
24
|
+
a control.
|
|
25
|
+
- A removable token in a multi-select → **InputChip** (see ChipInput).
|
|
26
|
+
- A count beside a tab label → **Tabs** draws its own mono number, not a
|
|
27
|
+
Chip.
|
|
28
|
+
|
|
29
|
+
## How
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { Chip } from '@estiva-app/ui'
|
|
33
|
+
|
|
34
|
+
<Chip type="success" label="Done" />
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
- `leadingIcon` / `trailingIcon` take a 12px icon; the slots centre it, so
|
|
38
|
+
it never rides the text baseline.
|
|
39
|
+
- A count alone works too — `label="3"`, no icon.
|
|
40
|
+
|
|
41
|
+
## Props
|
|
42
|
+
|
|
43
|
+
<Controls of={ChipStories.Neutral} />
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
|
|
2
|
+
import * as ChipInputStories from './ChipInput.stories'
|
|
3
|
+
|
|
4
|
+
<Meta of={ChipInputStories} />
|
|
5
|
+
|
|
6
|
+
# ChipInput
|
|
7
|
+
|
|
8
|
+
A multi-select input: chips for the chosen, a typeahead for the rest.
|
|
9
|
+
Nothing in it knows what is being picked — people, labels, files — the
|
|
10
|
+
caller hands in `options` and, when the entries have faces, the two
|
|
11
|
+
leading slots.
|
|
12
|
+
|
|
13
|
+
<Canvas of={ChipInputStories.WithChips} />
|
|
14
|
+
|
|
15
|
+
## When
|
|
16
|
+
|
|
17
|
+
- Choosing several entries from a directory: members for a group,
|
|
18
|
+
recipients, tags.
|
|
19
|
+
- **InputChip** is exported on its own for the display-only case — chosen
|
|
20
|
+
things shown somewhere no editing happens.
|
|
21
|
+
|
|
22
|
+
<Canvas of={ChipInputStories.TheChipItself} />
|
|
23
|
+
|
|
24
|
+
## When not
|
|
25
|
+
|
|
26
|
+
- One value → **Select**.
|
|
27
|
+
- Free text that becomes an entry — it only picks from `options`; if
|
|
28
|
+
typing must create things, that is your surface's logic around it.
|
|
29
|
+
|
|
30
|
+
## How
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { ChipInput } from '@estiva-app/ui'
|
|
34
|
+
|
|
35
|
+
<ChipInput
|
|
36
|
+
value={chosen}
|
|
37
|
+
onChange={setChosen}
|
|
38
|
+
options={directory}
|
|
39
|
+
chipLeading={(o) => <Avatar name={o.label} src={o.picture} size={16} />}
|
|
40
|
+
rowLeading={(o) => <Avatar name={o.label} src={o.picture} size={32} />}
|
|
41
|
+
excludeIds={[viewer.id]}
|
|
42
|
+
/>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- An option is `{ id, label, description? }` — the description is the
|
|
46
|
+
suggestion row's second line, and is searched too. The component is
|
|
47
|
+
**generic over your option type**: `onChange` hands back your own
|
|
48
|
+
objects, extra fields and all, no re-mapping.
|
|
49
|
+
- `chipLeading` is 16px in a chip; `rowLeading` is 32px in a suggestion
|
|
50
|
+
row. The chip's padding follows its contents — a face sits 2px from the
|
|
51
|
+
edge, a bare label gets 8px.
|
|
52
|
+
- Suggestions appear **only once the user types** — focusing must not drop
|
|
53
|
+
the whole directory over the surface below.
|
|
54
|
+
|
|
55
|
+
## Keys
|
|
56
|
+
|
|
57
|
+
| Input | What happens |
|
|
58
|
+
|---|---|
|
|
59
|
+
| typing | filters; suggestions appear |
|
|
60
|
+
| ↑ / ↓ | move the highlight |
|
|
61
|
+
| Enter | adds the highlighted entry |
|
|
62
|
+
| Backspace, empty query | removes the last chip — consumed, never the surface's "back" |
|
|
63
|
+
| Escape, with a query | clears the query — consumed |
|
|
64
|
+
| Escape, idle | bubbles, so the dialog or launcher around it can act |
|
|
65
|
+
|
|
66
|
+
## Props
|
|
67
|
+
|
|
68
|
+
<Controls of={ChipInputStories.TypeToSearch} />
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Avatar } from './Avatar'
|
|
4
|
+
import { ChipInput, InputChip, type ChipInputOption } from './ChipInput'
|
|
5
|
+
|
|
6
|
+
const PEOPLE: ChipInputOption[] = [
|
|
7
|
+
{ id: '1', name: 'Ana Duarte', description: 'Design' },
|
|
8
|
+
{ id: '2', name: 'Ravi Mehta', description: 'Engineering' },
|
|
9
|
+
{ id: '3', name: 'Marta Silva', description: 'Product' },
|
|
10
|
+
{ id: '4', name: 'Jonas Weber', description: 'Engineering' },
|
|
11
|
+
{ id: '5', name: 'Lea Novak', description: 'Research' },
|
|
12
|
+
].map(({ id, name, description }) => ({ id, label: name, description }))
|
|
13
|
+
|
|
14
|
+
const LABELS: ChipInputOption[] = [
|
|
15
|
+
{ id: 'a', label: 'Amber' },
|
|
16
|
+
{ id: 'b', label: 'Cobalt' },
|
|
17
|
+
{ id: 'c', label: 'Moss' },
|
|
18
|
+
{ id: 'd', label: 'Slate' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
/** The person flavour: faces in the chips and the rows, from the two leading
|
|
22
|
+
* slots — the Avatar at its own corners, never forced into a circle. */
|
|
23
|
+
const personLeading = {
|
|
24
|
+
chipLeading: (o: ChipInputOption) => <Avatar size={16} name={o.label} alt={o.label} />,
|
|
25
|
+
rowLeading: (o: ChipInputOption) => <Avatar size={32} name={o.label} alt={o.label} />,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const meta = {
|
|
29
|
+
title: 'Inputs/ChipInput',
|
|
30
|
+
component: ChipInput,
|
|
31
|
+
parameters: {
|
|
32
|
+
// The suggestion list portals to document.body at fixed coordinates —
|
|
33
|
+
// render docs usage in an iframe so it lands where the field is.
|
|
34
|
+
docs: { story: { inline: false, height: '320px' } },
|
|
35
|
+
},
|
|
36
|
+
args: { value: [], onChange: () => {}, options: PEOPLE },
|
|
37
|
+
argTypes: {
|
|
38
|
+
value: { control: false },
|
|
39
|
+
onChange: { control: false },
|
|
40
|
+
options: { control: false },
|
|
41
|
+
chipLeading: { control: false },
|
|
42
|
+
rowLeading: { control: false },
|
|
43
|
+
},
|
|
44
|
+
decorators: [(Story) => <div className="w-96">{Story()}</div>],
|
|
45
|
+
} satisfies Meta<typeof ChipInput>
|
|
46
|
+
|
|
47
|
+
export default meta
|
|
48
|
+
type Story = StoryObj<typeof meta>
|
|
49
|
+
|
|
50
|
+
/** Type a name — suggestions appear only once there is a query. */
|
|
51
|
+
export const TypeToSearch: Story = {
|
|
52
|
+
parameters: { controls: { disable: true } },
|
|
53
|
+
render: (args) => {
|
|
54
|
+
const [value, setValue] = useState<ChipInputOption[]>([])
|
|
55
|
+
return <ChipInput {...args} {...personLeading} value={value} onChange={setValue} placeholder="Search people…" />
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Chosen people are chips. Backspace on an empty query removes the last one. */
|
|
60
|
+
export const WithChips: Story = {
|
|
61
|
+
parameters: { controls: { disable: true } },
|
|
62
|
+
render: (args) => {
|
|
63
|
+
const [value, setValue] = useState<ChipInputOption[]>([PEOPLE[0], PEOPLE[1]])
|
|
64
|
+
return <ChipInput {...args} {...personLeading} value={value} onChange={setValue} placeholder="Search people…" />
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `excludeIds` keeps someone out of the suggestions — the current user, say. */
|
|
69
|
+
export const ExcludingSomeone: Story = {
|
|
70
|
+
parameters: { controls: { disable: true } },
|
|
71
|
+
render: (args) => {
|
|
72
|
+
const [value, setValue] = useState<ChipInputOption[]>([])
|
|
73
|
+
return <ChipInput {...args} {...personLeading} value={value} onChange={setValue} excludeIds={['1']} placeholder="Search people…" />
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Nothing about people: no leading slots, and the chips are plain labels.
|
|
79
|
+
* The same input picks anything multi-select.
|
|
80
|
+
*/
|
|
81
|
+
export const PlainLabels: Story = {
|
|
82
|
+
parameters: { controls: { disable: true } },
|
|
83
|
+
render: (args) => {
|
|
84
|
+
const [value, setValue] = useState<ChipInputOption[]>([LABELS[0]])
|
|
85
|
+
return <ChipInput {...args} value={value} onChange={setValue} options={LABELS} placeholder="Add a label…" />
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The chip on its own: with a face, with nothing, and display-only (no ✕). */
|
|
90
|
+
export const TheChipItself: Story = {
|
|
91
|
+
parameters: { controls: { disable: true } },
|
|
92
|
+
render: () => (
|
|
93
|
+
<div className="flex items-center gap-2">
|
|
94
|
+
<InputChip label="Ana Duarte" leading={<Avatar size={16} name="Ana Duarte" alt="Ana Duarte" />} onRemove={() => {}} />
|
|
95
|
+
<InputChip label="Amber" onRemove={() => {}} />
|
|
96
|
+
<InputChip label="Display only" />
|
|
97
|
+
</div>
|
|
98
|
+
),
|
|
99
|
+
}
|