@estiva-app/ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/README.md +12 -9
  2. package/base.css +43 -0
  3. package/dist/AppShell.d.ts +39 -0
  4. package/dist/AppShell.d.ts.map +1 -0
  5. package/dist/Avatar.d.ts +7 -2
  6. package/dist/Avatar.d.ts.map +1 -1
  7. package/dist/AvatarGroup.d.ts +23 -0
  8. package/dist/AvatarGroup.d.ts.map +1 -0
  9. package/dist/Banner.d.ts +19 -0
  10. package/dist/Banner.d.ts.map +1 -0
  11. package/dist/Breadcrumb.d.ts +28 -0
  12. package/dist/Breadcrumb.d.ts.map +1 -0
  13. package/dist/Checkbox.d.ts +18 -0
  14. package/dist/Checkbox.d.ts.map +1 -0
  15. package/dist/ChipInput.d.ts +57 -0
  16. package/dist/ChipInput.d.ts.map +1 -0
  17. package/dist/ConfirmDialog.d.ts +22 -0
  18. package/dist/ConfirmDialog.d.ts.map +1 -0
  19. package/dist/DialogShell.d.ts +9 -2
  20. package/dist/DialogShell.d.ts.map +1 -1
  21. package/dist/EditableText.d.ts +56 -0
  22. package/dist/EditableText.d.ts.map +1 -0
  23. package/dist/IdentityMenu.d.ts +58 -0
  24. package/dist/IdentityMenu.d.ts.map +1 -0
  25. package/dist/Menu.d.ts +82 -0
  26. package/dist/Menu.d.ts.map +1 -0
  27. package/dist/NavItem.d.ts +26 -0
  28. package/dist/NavItem.d.ts.map +1 -0
  29. package/dist/Person.d.ts +26 -0
  30. package/dist/Person.d.ts.map +1 -0
  31. package/dist/PersonTrigger.d.ts +23 -0
  32. package/dist/PersonTrigger.d.ts.map +1 -0
  33. package/dist/Property.d.ts +24 -0
  34. package/dist/Property.d.ts.map +1 -0
  35. package/dist/Rail.d.ts +18 -0
  36. package/dist/Rail.d.ts.map +1 -0
  37. package/dist/RailItem.d.ts +23 -0
  38. package/dist/RailItem.d.ts.map +1 -0
  39. package/dist/SearchInput.d.ts +21 -0
  40. package/dist/SearchInput.d.ts.map +1 -0
  41. package/dist/SectionHeader.d.ts +34 -0
  42. package/dist/SectionHeader.d.ts.map +1 -0
  43. package/dist/SectionLabel.d.ts +19 -0
  44. package/dist/SectionLabel.d.ts.map +1 -0
  45. package/dist/Select.d.ts +42 -5
  46. package/dist/Select.d.ts.map +1 -1
  47. package/dist/Sidebar.d.ts +18 -0
  48. package/dist/Sidebar.d.ts.map +1 -0
  49. package/dist/Tabs.d.ts +34 -0
  50. package/dist/Tabs.d.ts.map +1 -0
  51. package/dist/Toast.d.ts +48 -0
  52. package/dist/Toast.d.ts.map +1 -0
  53. package/dist/Tooltip.d.ts +3 -1
  54. package/dist/Tooltip.d.ts.map +1 -1
  55. package/dist/TopBar.d.ts +33 -0
  56. package/dist/TopBar.d.ts.map +1 -0
  57. package/dist/cn.d.ts +14 -4
  58. package/dist/cn.d.ts.map +1 -1
  59. package/dist/index.d.ts +23 -0
  60. package/dist/index.d.ts.map +1 -1
  61. package/dist/index.js +1254 -106
  62. package/dist/index.js.map +4 -4
  63. package/package.json +5 -2
  64. package/src/AppShell.mdx +66 -0
  65. package/src/AppShell.stories.tsx +117 -0
  66. package/src/AppShell.tsx +77 -0
  67. package/src/Avatar.initials.test.ts +21 -0
  68. package/src/Avatar.mdx +49 -0
  69. package/src/Avatar.tsx +12 -9
  70. package/src/AvatarGroup.mdx +39 -0
  71. package/src/AvatarGroup.stories.tsx +56 -0
  72. package/src/AvatarGroup.tsx +42 -0
  73. package/src/Banner.mdx +45 -0
  74. package/src/Banner.stories.tsx +35 -0
  75. package/src/Banner.tsx +35 -0
  76. package/src/Breadcrumb.mdx +52 -0
  77. package/src/Breadcrumb.stories.tsx +42 -0
  78. package/src/Breadcrumb.tsx +106 -0
  79. package/src/Button.mdx +57 -0
  80. package/src/Checkbox.mdx +44 -0
  81. package/src/Checkbox.stories.tsx +49 -0
  82. package/src/Checkbox.tsx +46 -0
  83. package/src/Chip.mdx +43 -0
  84. package/src/ChipInput.mdx +68 -0
  85. package/src/ChipInput.stories.tsx +99 -0
  86. package/src/ChipInput.tsx +247 -0
  87. package/src/ConfirmDialog.mdx +51 -0
  88. package/src/ConfirmDialog.stories.tsx +63 -0
  89. package/src/ConfirmDialog.tsx +55 -0
  90. package/src/DialogShell.mdx +68 -0
  91. package/src/DialogShell.tsx +12 -5
  92. package/src/Divider.mdx +41 -0
  93. package/src/EditableText.mdx +59 -0
  94. package/src/EditableText.stories.tsx +63 -0
  95. package/src/EditableText.tsx +169 -0
  96. package/src/EmptyState.mdx +41 -0
  97. package/src/Field.mdx +39 -0
  98. package/src/IconButton.mdx +47 -0
  99. package/src/IdentityMenu.mdx +62 -0
  100. package/src/IdentityMenu.stories.tsx +52 -0
  101. package/src/IdentityMenu.tsx +159 -0
  102. package/src/Menu.mdx +111 -0
  103. package/src/Menu.stories.tsx +66 -0
  104. package/src/Menu.tsx +172 -0
  105. package/src/MenuItem.mdx +55 -0
  106. package/src/MenuItem.stories.tsx +98 -0
  107. package/src/NavItem.mdx +53 -0
  108. package/src/NavItem.stories.tsx +36 -0
  109. package/src/NavItem.tsx +53 -0
  110. package/src/Person.mdx +44 -0
  111. package/src/Person.stories.tsx +43 -0
  112. package/src/Person.tsx +39 -0
  113. package/src/PersonTrigger.mdx +51 -0
  114. package/src/PersonTrigger.stories.tsx +30 -0
  115. package/src/PersonTrigger.tsx +62 -0
  116. package/src/Property.mdx +51 -0
  117. package/src/Property.stories.tsx +91 -0
  118. package/src/Property.tsx +44 -0
  119. package/src/Rail.tsx +26 -0
  120. package/src/RailItem.mdx +50 -0
  121. package/src/RailItem.stories.tsx +37 -0
  122. package/src/RailItem.tsx +53 -0
  123. package/src/SearchInput.mdx +39 -0
  124. package/src/SearchInput.stories.tsx +24 -0
  125. package/src/SearchInput.tsx +45 -0
  126. package/src/SectionHeader.mdx +52 -0
  127. package/src/SectionHeader.stories.tsx +66 -0
  128. package/src/SectionHeader.tsx +85 -0
  129. package/src/SectionLabel.mdx +42 -0
  130. package/src/SectionLabel.stories.tsx +31 -0
  131. package/src/SectionLabel.tsx +28 -0
  132. package/src/Select.fit.test.ts +97 -0
  133. package/src/Select.mdx +71 -0
  134. package/src/Select.stories.tsx +95 -0
  135. package/src/Select.tsx +123 -15
  136. package/src/Sidebar.mdx +56 -0
  137. package/src/Sidebar.stories.tsx +50 -0
  138. package/src/Sidebar.tsx +32 -0
  139. package/src/Skeleton.mdx +39 -0
  140. package/src/Tabs.mdx +53 -0
  141. package/src/Tabs.stories.tsx +91 -0
  142. package/src/Tabs.tsx +73 -0
  143. package/src/TextInput.mdx +39 -0
  144. package/src/Textarea.mdx +36 -0
  145. package/src/Toast.mdx +54 -0
  146. package/src/Toast.stories.tsx +87 -0
  147. package/src/Toast.tsx +169 -0
  148. package/src/Tooltip.mdx +51 -0
  149. package/src/Tooltip.tsx +4 -2
  150. package/src/TopBar.mdx +62 -0
  151. package/src/TopBar.stories.tsx +96 -0
  152. package/src/TopBar.tsx +57 -0
  153. package/src/cn.test.ts +27 -0
  154. package/src/cn.ts +25 -5
  155. package/src/index.ts +23 -0
  156. package/tailwind-preset.js +4 -3
  157. 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} />
@@ -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
+ }
@@ -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
+ }