@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.
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 +36 -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 +150 -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,247 @@
1
+ import { useState, useRef, useMemo, useEffect, useLayoutEffect, type KeyboardEvent, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { IconX } from '@tabler/icons-react'
4
+ import { cn } from './cn'
5
+ import { MenuItem } from './Menu'
6
+
7
+ /**
8
+ * The chip a `ChipInput` is made of: a 24px pill with an optional 16px
9
+ * leading (a face, an icon), a 12px label, and the ✕ that removes it.
10
+ * Exported on its own (Katerina, 2026-09-01) under a name that promises
11
+ * nothing about people — a chip like this may one day hold a label, a file,
12
+ * a filter.
13
+ */
14
+ export interface InputChipProps {
15
+ label: string
16
+ /** Before the label, 16px — an Avatar, an icon. */
17
+ leading?: ReactNode
18
+ /** Draws the ✕; absent, the chip is display-only. */
19
+ onRemove?: () => void
20
+ className?: string
21
+ }
22
+
23
+ export function InputChip({ label, leading, onRemove, className }: InputChipProps) {
24
+ return (
25
+ <div
26
+ className={cn(
27
+ // Curved, not a pill (Katerina, 2026-09-01): the Avatar keeps its own
28
+ // rounded-sm corners — never a forced circle — and the chip's corner
29
+ // follows concentrically: 4px face + 2px inset = rounded-md.
30
+ 'inline-flex items-center gap-1.5 bg-bg-elevated border border-border-subtle rounded-md py-0.5 max-h-[24px]',
31
+ // The padding follows the contents (Katerina, 2026-09-01): a face
32
+ // sits 2px from the edge, a bare label needs 8px of air; the ✕
33
+ // brings its own box, so 4px behind it — 8px when there isn't one.
34
+ leading ? 'pl-[2px]' : 'pl-2',
35
+ onRemove ? 'pr-1' : 'pr-2',
36
+ className,
37
+ )}
38
+ >
39
+ {leading && <span className="flex shrink-0 items-center">{leading}</span>}
40
+ <span className="text-caption font-medium text-text-primary">{label}</span>
41
+ {onRemove && (
42
+ <button
43
+ type="button"
44
+ onClick={(e) => {
45
+ e.stopPropagation()
46
+ onRemove()
47
+ }}
48
+ className="size-4 flex items-center justify-center rounded-full hover:bg-bg-hover text-text-secondary"
49
+ aria-label={`Remove ${label}`}
50
+ >
51
+ <IconX size={10} stroke={1.5} />
52
+ </button>
53
+ )}
54
+ </div>
55
+ )
56
+ }
57
+
58
+ /**
59
+ * A multi-select input: chips for the chosen, a typeahead for the rest —
60
+ * Peek's PersonChipInput (2026-09-01), generalised on the way in. Peek's
61
+ * version knew it was picking people: it read the directory from Peek's own
62
+ * data layer and drew every face itself. Here the caller hands in `options`,
63
+ * and — when the entries have faces or icons — the two leading slots: 16px
64
+ * in a chip, 32px in a suggestion row. Nothing in this file knows what is
65
+ * being picked.
66
+ *
67
+ * Suggestions appear only once the user types — focusing (or auto-focus on
68
+ * dialog open) must not drop the full directory over the surface below.
69
+ * Backspace on an empty query removes the last chip; Escape clears the query
70
+ * when there is one and bubbles when there is not, so the surface around it
71
+ * (dialog, launcher) can act.
72
+ *
73
+ * Generic over the option type: the objects handed back through `onChange`
74
+ * are the caller's own, extra fields and all — no re-mapping on the way out.
75
+ */
76
+ export interface ChipInputOption {
77
+ id: string
78
+ label: string
79
+ /** The suggestion row's second line — a role, an address. Also searched. */
80
+ description?: string
81
+ }
82
+
83
+ export interface ChipInputProps<T extends ChipInputOption = ChipInputOption> {
84
+ value: T[]
85
+ onChange: (next: T[]) => void
86
+ /** The directory the typeahead searches. */
87
+ options: T[]
88
+ placeholder?: string
89
+ autoFocus?: boolean
90
+ /** Option ids excluded from the suggestion list (e.g., the current user). */
91
+ excludeIds?: string[]
92
+ /** Before a chip's label, 16px — a face, an icon. */
93
+ chipLeading?: (option: T) => ReactNode
94
+ /** Before a suggestion row's label, 32px. */
95
+ rowLeading?: (option: T) => ReactNode
96
+ }
97
+
98
+ export function ChipInput<T extends ChipInputOption = ChipInputOption>({
99
+ value,
100
+ onChange,
101
+ options,
102
+ placeholder = 'Search…',
103
+ autoFocus,
104
+ excludeIds = [],
105
+ chipLeading,
106
+ rowLeading,
107
+ }: ChipInputProps<T>) {
108
+ const [query, setQuery] = useState('')
109
+ const [highlight, setHighlight] = useState(0)
110
+ const [isFocused, setIsFocused] = useState(false)
111
+ const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null)
112
+ const wrapperRef = useRef<HTMLDivElement>(null)
113
+ const inputRef = useRef<HTMLInputElement>(null)
114
+
115
+ const matches = useMemo(() => {
116
+ const selectedIds = new Set(value.map((o) => o.id))
117
+ const excludedIds = new Set(excludeIds)
118
+ const q = query.trim().toLowerCase()
119
+ return options.filter((o) => {
120
+ if (selectedIds.has(o.id)) return false
121
+ if (excludedIds.has(o.id)) return false
122
+ if (!q) return true
123
+ return o.label.toLowerCase().includes(q) || (o.description ?? '').toLowerCase().includes(q)
124
+ })
125
+ }, [query, value, excludeIds, options])
126
+
127
+ useEffect(() => {
128
+ setHighlight(0)
129
+ }, [query, matches.length])
130
+
131
+ const showDropdown = isFocused && query.trim().length > 0 && matches.length > 0
132
+
133
+ useLayoutEffect(() => {
134
+ if (!showDropdown) return
135
+ const update = () => {
136
+ if (wrapperRef.current) setAnchorRect(wrapperRef.current.getBoundingClientRect())
137
+ }
138
+ update()
139
+ window.addEventListener('resize', update)
140
+ window.addEventListener('scroll', update, true)
141
+ return () => {
142
+ window.removeEventListener('resize', update)
143
+ window.removeEventListener('scroll', update, true)
144
+ }
145
+ }, [showDropdown, value.length])
146
+
147
+ function addOption(option: T) {
148
+ onChange([...value, option])
149
+ setQuery('')
150
+ inputRef.current?.focus()
151
+ }
152
+
153
+ function removeOption(id: string) {
154
+ onChange(value.filter((o) => o.id !== id))
155
+ }
156
+
157
+ function handleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
158
+ if (e.key === 'Backspace' && query === '' && value.length > 0) {
159
+ // Consumed: removing a chip must not double as the surface's "back".
160
+ e.preventDefault()
161
+ removeOption(value[value.length - 1].id)
162
+ return
163
+ }
164
+ if (e.key === 'ArrowDown') {
165
+ e.preventDefault()
166
+ setHighlight((h) => Math.min(h + 1, Math.max(0, matches.length - 1)))
167
+ return
168
+ }
169
+ if (e.key === 'ArrowUp') {
170
+ e.preventDefault()
171
+ setHighlight((h) => Math.max(h - 1, 0))
172
+ return
173
+ }
174
+ if (e.key === 'Enter') {
175
+ e.preventDefault()
176
+ const target = matches[highlight]
177
+ if (target) addOption(target)
178
+ return
179
+ }
180
+ if (e.key === 'Escape') {
181
+ // Consume it only when there is something to clear; an idle input lets
182
+ // Escape bubble so the surface around it (dialog, launcher) can act.
183
+ if (query !== '') {
184
+ e.preventDefault()
185
+ setQuery('')
186
+ }
187
+ }
188
+ }
189
+
190
+ return (
191
+ <div className="relative">
192
+ <div
193
+ ref={wrapperRef}
194
+ className="bg-bg-inset border border-border-default hover:border-border-strong focus-within:border-border-focus focus-within:hover:border-border-focus rounded-lg px-3 py-1.5 flex flex-wrap items-center gap-1.5 transition-colors min-h-[38px] cursor-text signal:transition-shadow signal:focus-within:shadow-focus-ring"
195
+ onClick={() => inputRef.current?.focus()}
196
+ >
197
+ {value.map((o) => (
198
+ <InputChip key={o.id} label={o.label} leading={chipLeading?.(o)} onRemove={() => removeOption(o.id)} />
199
+ ))}
200
+
201
+ <input
202
+ ref={inputRef}
203
+ autoFocus={autoFocus}
204
+ type="text"
205
+ value={query}
206
+ onChange={(e) => setQuery(e.target.value)}
207
+ onKeyDown={handleKeyDown}
208
+ onFocus={() => setIsFocused(true)}
209
+ onBlur={() => {
210
+ setTimeout(() => setIsFocused(false), 150)
211
+ }}
212
+ placeholder={value.length === 0 ? placeholder : ''}
213
+ className="flex-1 min-w-[120px] bg-transparent text-body-2 text-text-primary placeholder:text-text-muted outline-none border-none"
214
+ />
215
+ </div>
216
+
217
+ {showDropdown && anchorRect && createPortal(
218
+ <div
219
+ className="fixed z-[60] max-h-[240px] overflow-y-auto bg-bg-elevated border border-border-default rounded-lg shadow-lg"
220
+ style={{
221
+ top: anchorRect.bottom + 4,
222
+ left: anchorRect.left,
223
+ width: anchorRect.width,
224
+ }}
225
+ >
226
+ {matches.map((o, i) => (
227
+ <MenuItem
228
+ key={o.id}
229
+ size="tall"
230
+ className="h-12 rounded-none"
231
+ leading={rowLeading?.(o)}
232
+ label={o.label}
233
+ description={o.description}
234
+ selected={i === highlight}
235
+ onMouseEnter={() => setHighlight(i)}
236
+ onMouseDown={(e) => {
237
+ e.preventDefault()
238
+ addOption(o)
239
+ }}
240
+ />
241
+ ))}
242
+ </div>,
243
+ document.body
244
+ )}
245
+ </div>
246
+ )
247
+ }
@@ -0,0 +1,51 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as ConfirmDialogStories from './ConfirmDialog.stories'
3
+
4
+ <Meta of={ConfirmDialogStories} />
5
+
6
+ # ConfirmDialog
7
+
8
+ A question with two answers, asked before something a person would want
9
+ back. Built on DialogShell; the body is plain sentences in `body-2`.
10
+
11
+ <Canvas of={ConfirmDialogStories.Destructive} />
12
+
13
+ ## When
14
+
15
+ - Before a deletion, an archive, a handover — anything hard to undo. The
16
+ confirm button is `destructive` when the action is.
17
+ - A non-destructive but consequential step confirms with `primary`.
18
+
19
+ ## When not
20
+
21
+ - Gathering input first → **DialogShell** with a form.
22
+ - Telling someone what already happened → **Toast**.
23
+ - Guarding something trivially reversible — don't; an undo beats a
24
+ question.
25
+
26
+ ## How
27
+
28
+ ```tsx
29
+ import { ConfirmDialog } from '@estiva-app/ui'
30
+
31
+ {confirming && (
32
+ <ConfirmDialog
33
+ title="Delete this?"
34
+ confirmLabel="Delete"
35
+ destructive
36
+ onConfirm={() => remove()}
37
+ onClose={() => setConfirming(false)}
38
+ >
39
+ <p>This removes it for everyone. There is no undo.</p>
40
+ </ConfirmDialog>
41
+ )}
42
+ ```
43
+
44
+ - While `onConfirm` runs, both buttons wait. Resolving `false` keeps the
45
+ dialog open — your surface has said why; a resolved `void`/`true`
46
+ closes it.
47
+ - Escape and the backdrop click cancel, via the shell.
48
+
49
+ ## Props
50
+
51
+ <Controls of={ConfirmDialogStories.Destructive} />
@@ -0,0 +1,63 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+ import { Button } from './Button'
4
+ import { ConfirmDialog } from './ConfirmDialog'
5
+
6
+ const meta = {
7
+ title: 'Overlays/ConfirmDialog',
8
+ component: ConfirmDialog,
9
+ parameters: {
10
+ layout: 'fullscreen',
11
+ // Portals a fixed overlay to document.body — render in an iframe on the
12
+ // Docs page so it doesn't escape over the docs content.
13
+ docs: { story: { inline: false, height: '400px' } },
14
+ },
15
+ argTypes: { children: { control: false }, onConfirm: { control: false } },
16
+ args: {
17
+ title: 'Delete this?',
18
+ confirmLabel: 'Delete',
19
+ destructive: true,
20
+ onConfirm: () => {},
21
+ onClose: () => {},
22
+ children: 'This permanently removes it. It cannot be undone.',
23
+ },
24
+ } satisfies Meta<typeof ConfirmDialog>
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof meta>
28
+
29
+ export const Destructive: Story = {}
30
+
31
+ /** A confirmation that isn't a loss — the confirm button stays primary. */
32
+ export const NonDestructive: Story = {
33
+ args: {
34
+ title: 'Hand this over?',
35
+ confirmLabel: 'Hand over',
36
+ destructive: false,
37
+ children: 'They become the owner. You keep reading rights.',
38
+ },
39
+ }
40
+
41
+ /**
42
+ * An action that resolves `false` keeps the dialog open — the caller has
43
+ * shown why, and closing over an unexplained failure would lose the moment
44
+ * to read it. Confirm here refuses forever; Cancel is the way out.
45
+ */
46
+ export const RefusalKeepsItOpen: Story = {
47
+ parameters: { controls: { disable: true } },
48
+ render: (args) => {
49
+ const [open, setOpen] = useState(true)
50
+ return (
51
+ <div className="flex h-screen items-center justify-center">
52
+ <Button variant="primary" onClick={() => setOpen(true)}>
53
+ Open dialog
54
+ </Button>
55
+ {open && (
56
+ <ConfirmDialog {...args} onConfirm={() => false} onClose={() => setOpen(false)}>
57
+ Confirm resolves false, so the dialog stays. Cancel closes it.
58
+ </ConfirmDialog>
59
+ )}
60
+ </div>
61
+ )
62
+ },
63
+ }
@@ -0,0 +1,55 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { Button } from './Button'
3
+ import { DialogShell } from './DialogShell'
4
+
5
+ /**
6
+ * A question with two answers, before something a person would want back —
7
+ * Ship's ConfirmDialog (2026-09-01), verbatim. Its own comment always called
8
+ * it a package candidate: nothing here knows what is being confirmed.
9
+ *
10
+ * The confirm button is `destructive` when the action is (a deletion, an
11
+ * archive); `primary` otherwise. While the action runs the buttons wait;
12
+ * an action that resolves `false` keeps the dialog open, so the caller's
13
+ * banner can say why.
14
+ */
15
+ export interface ConfirmDialogProps {
16
+ title: string
17
+ /** The body — plain sentences, body-2. */
18
+ children: ReactNode
19
+ confirmLabel: string
20
+ destructive?: boolean
21
+ onConfirm: () => Promise<boolean | void> | boolean | void
22
+ onClose: () => void
23
+ }
24
+
25
+ export function ConfirmDialog({ title, children, confirmLabel, destructive = false, onConfirm, onClose }: ConfirmDialogProps) {
26
+ const [busy, setBusy] = useState(false)
27
+ const confirm = async () => {
28
+ setBusy(true)
29
+ try {
30
+ const ok = await onConfirm()
31
+ if (ok !== false) onClose()
32
+ } finally {
33
+ setBusy(false)
34
+ }
35
+ }
36
+ return (
37
+ <DialogShell
38
+ title={title}
39
+ onClose={onClose}
40
+ bodyClassName="flex flex-col gap-3 text-body-2 text-text-primary"
41
+ footer={
42
+ <>
43
+ <Button variant="muted" onClick={onClose} disabled={busy}>
44
+ Cancel
45
+ </Button>
46
+ <Button variant={destructive ? 'destructive' : 'primary'} onClick={() => void confirm()} disabled={busy}>
47
+ {confirmLabel}
48
+ </Button>
49
+ </>
50
+ }
51
+ >
52
+ {children}
53
+ </DialogShell>
54
+ )
55
+ }
@@ -0,0 +1,68 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as DialogShellStories from './DialogShell.stories'
3
+
4
+ <Meta of={DialogShellStories} />
5
+
6
+ # DialogShell
7
+
8
+ The dialog, minus what it says: the portal, the backdrop, a 502px card,
9
+ a 48px header with the title and a close button, a body, and a 48px
10
+ footer for the buttons. A dialog is just what goes in the three slots.
11
+
12
+ <Canvas of={DialogShellStories.Default} />
13
+
14
+ ## When
15
+
16
+ - Anything with a title, a body, and buttons: a form, a picker, a roster.
17
+ - `footer` absent removes the footer row entirely — a list that simply
18
+ ends keeps the card's own bottom edge.
19
+ - `headerContent` replaces the title text — a back button beside the
20
+ title, a count chip after it. The close button stays.
21
+
22
+ ## When not
23
+
24
+ - A short list of verbs from a trigger → **Menu**.
25
+ - "Are you sure?" → **ConfirmDialog** — it is this shell with the question
26
+ already wired.
27
+ - Telling someone what happened → **Toast**.
28
+
29
+ ## How
30
+
31
+ ```tsx
32
+ import { DialogShell, Button } from '@estiva-app/ui'
33
+
34
+ {open && (
35
+ <DialogShell
36
+ title="New document"
37
+ onClose={() => setOpen(false)}
38
+ footer={
39
+ <>
40
+ <Button onClick={() => setOpen(false)}>Cancel</Button>
41
+ <Button variant="primary" onClick={submit}>Create</Button>
42
+ </>
43
+ }
44
+ >
45
+ …fields…
46
+ </DialogShell>
47
+ )}
48
+ ```
49
+
50
+ - Render it only while open — mounting is opening.
51
+ - `bodyClassName` shapes the body (`flex flex-col gap-6`, or a max height
52
+ with `overflow-y-auto` for long lists). Rows in a height-capped flex
53
+ body need `shrink-0`, or overflow crushes them.
54
+ - The card names itself to assistive tech (`role="dialog"`, labelled by
55
+ `title`). Focus is not trapped or moved — give the first field
56
+ `autoFocus` where typing is the point.
57
+
58
+ ## Keys
59
+
60
+ | Input | What happens |
61
+ |---|---|
62
+ | Escape | closes |
63
+ | backdrop click | closes |
64
+ | close button | closes (it is an IconButton with its label) |
65
+
66
+ ## Props
67
+
68
+ <Controls of={DialogShellStories.Default} />
@@ -12,16 +12,23 @@ import { IconButton } from './IconButton'
12
12
  * says what it is to assistive tech.
13
13
  */
14
14
  export interface DialogShellProps {
15
+ /** Labels the dialog for assistive tech, and renders as the header text
16
+ * unless `headerContent` replaces it. */
15
17
  title: string
16
18
  onClose: () => void
17
- footer: ReactNode
19
+ /** Replaces the title text in the header — a back button beside the title,
20
+ * a count chip after it. The close button stays. */
21
+ headerContent?: ReactNode
22
+ /** Absent: no footer row, and the body keeps the card's own bottom edge
23
+ * (a roster that simply ends). */
24
+ footer?: ReactNode
18
25
  children: ReactNode
19
26
  /** Extra classes on the body (e.g. `flex flex-col gap-6`, or a max height with `overflow-y-auto`). */
20
27
  bodyClassName?: string
21
28
  width?: number
22
29
  }
23
30
 
24
- export function DialogShell({ title, onClose, footer, children, bodyClassName, width = 502 }: DialogShellProps) {
31
+ export function DialogShell({ title, onClose, headerContent, footer, children, bodyClassName, width = 502 }: DialogShellProps) {
25
32
  useEffect(() => {
26
33
  const onKey = (event: KeyboardEvent) => {
27
34
  if (event.key === 'Escape') onClose()
@@ -46,17 +53,17 @@ export function DialogShell({ title, onClose, footer, children, bodyClassName, w
46
53
  >
47
54
  {/* Header */}
48
55
  <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>
56
+ {headerContent ?? <span className="text-h4 text-text-primary">{title}</span>}
50
57
  <IconButton tooltip="Close" aria-label="Close" onClick={onClose}>
51
58
  <IconX size={16} stroke={1.5} />
52
59
  </IconButton>
53
60
  </div>
54
61
 
55
62
  {/* Body */}
56
- <div className={cn('pl-5 pr-4 py-4 border-b border-border-subtle', bodyClassName)}>{children}</div>
63
+ <div className={cn('pl-5 pr-4 py-4', footer != null && 'border-b border-border-subtle', bodyClassName)}>{children}</div>
57
64
 
58
65
  {/* Footer */}
59
- <div className="h-12 flex items-center justify-end gap-2 pl-5 pr-4 shrink-0">{footer}</div>
66
+ {footer != null && <div className="h-12 flex items-center justify-end gap-2 pl-5 pr-4 shrink-0">{footer}</div>}
60
67
  </div>
61
68
  </div>
62
69
  </>,
@@ -0,0 +1,41 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as DividerStories from './Divider.stories'
3
+
4
+ <Meta of={DividerStories} />
5
+
6
+ # Divider
7
+
8
+ A hairline in `border-subtle`. Horizontal it is inset 12px each side;
9
+ `orientation="vertical"` stands it up to its row's height, no inset. It
10
+ carries the `separator` role for assistive tech.
11
+
12
+ <Canvas of={DividerStories.Default} />
13
+
14
+ ## When
15
+
16
+ - Between groups in a **Menu** (`className="my-1"` gives it its breathing
17
+ room there).
18
+ - Between sections of a panel or rail.
19
+ - Vertical: between clusters in a toolbar or a dense row.
20
+
21
+ <Canvas of={DividerStories.Vertical} />
22
+
23
+ ## When not
24
+
25
+ - Between every row of a list — spacing does that; a hairline per row is
26
+ noise.
27
+ - To title what follows → **SectionLabel** or **SectionHeader**; a heading
28
+ separates better than a line.
29
+
30
+ ## How
31
+
32
+ ```tsx
33
+ import { Divider } from '@estiva-app/ui'
34
+
35
+ <Divider />
36
+ <Divider orientation="vertical" />
37
+ ```
38
+
39
+ ## Props
40
+
41
+ <Controls of={DividerStories.Default} />
@@ -0,0 +1,59 @@
1
+ import { Meta, Canvas, Controls } from '@storybook/addon-docs/blocks'
2
+ import * as EditableTextStories from './EditableText.stories'
3
+
4
+ <Meta of={EditableTextStories} />
5
+
6
+ # EditableText
7
+
8
+ Text you click to edit. It reads as text until clicked; then it is a
9
+ field. A commit that fails keeps the field open with the text in it, so an
10
+ edit is never silently lost.
11
+
12
+ <Canvas of={EditableTextStories.Title} />
13
+
14
+ ## When
15
+
16
+ - A title or description edited where it is shown — no dialog, no separate
17
+ form.
18
+ - A reader who cannot write gets `readOnly` — the value without the edit
19
+ affordance, because an action appears only where it can succeed.
20
+
21
+ ## When not
22
+
23
+ - A field in a form or dialog → **TextInput** / **Textarea** in a
24
+ **Field**.
25
+ - Composing something new → the field, not an edit-in-place of nothing.
26
+
27
+ ## How
28
+
29
+ ```tsx
30
+ import { EditableText } from '@estiva-app/ui'
31
+
32
+ <EditableText
33
+ value={title}
34
+ placeholder="Untitled"
35
+ label="Title"
36
+ onCommit={async (next) => save(next)}
37
+ />
38
+ ```
39
+
40
+ - `onCommit` resolves `true` to close; `false` (or a throw) keeps the
41
+ field open — you have already told the person why. An unchanged value is
42
+ not committed at all.
43
+ - `label` is required — it is the field's accessible name.
44
+ - `display` shows something other than what is edited (a value carrying
45
+ raw references, rendered richer when read) without losing the original
46
+ on commit.
47
+
48
+ ## Keys
49
+
50
+ | Input | What happens |
51
+ |---|---|
52
+ | click the text | opens the field, selects the value |
53
+ | Enter | commits (`multiline`: Shift+Enter is a new line) |
54
+ | Escape | cancels, restores the value |
55
+ | blur | commits |
56
+
57
+ ## Props
58
+
59
+ <Controls of={EditableTextStories.Title} />