@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,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} />
|
package/src/DialogShell.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
</>,
|
package/src/Divider.mdx
ADDED
|
@@ -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} />
|