@lovett/ui 0.0.1

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 (102) hide show
  1. package/README.md +28 -0
  2. package/package.json +53 -0
  3. package/src/accordion.tsx +153 -0
  4. package/src/atoms.tsx +197 -0
  5. package/src/badge.tsx +91 -0
  6. package/src/brand-logo-tile.tsx +200 -0
  7. package/src/bulk-action-bar.tsx +104 -0
  8. package/src/button-group.tsx +46 -0
  9. package/src/button.tsx +93 -0
  10. package/src/calculator-shell-v2.tsx +380 -0
  11. package/src/calculator-shell.tsx +310 -0
  12. package/src/card.tsx +369 -0
  13. package/src/checkbox.tsx +114 -0
  14. package/src/chip-nav.tsx +347 -0
  15. package/src/choropleth.tsx +62 -0
  16. package/src/code-block.tsx +235 -0
  17. package/src/completion-ring.tsx +119 -0
  18. package/src/copy-field.tsx +169 -0
  19. package/src/data-grid/column-options-menu.tsx +127 -0
  20. package/src/data-grid/data-grid.tsx +391 -0
  21. package/src/data-grid/drawer-panel.tsx +146 -0
  22. package/src/data-grid/index.ts +29 -0
  23. package/src/data-grid/sortable-parts.tsx +215 -0
  24. package/src/data-grid/table-body.tsx +238 -0
  25. package/src/data-grid/table-elements.tsx +135 -0
  26. package/src/data-grid/table-header.tsx +95 -0
  27. package/src/data-grid/table-overlays.tsx +78 -0
  28. package/src/data-grid/table-parts.tsx +15 -0
  29. package/src/data-grid/table-summary-footer.tsx +101 -0
  30. package/src/data-grid/table-view.tsx +176 -0
  31. package/src/data-grid/types.ts +141 -0
  32. package/src/data-grid/use-grid-columns.ts +311 -0
  33. package/src/data-grid/use-grid-editing.ts +154 -0
  34. package/src/device-frame.tsx +274 -0
  35. package/src/dropdown-button.tsx +221 -0
  36. package/src/dropdown-menu.tsx +450 -0
  37. package/src/empty-state.tsx +85 -0
  38. package/src/file-upload/file-drop-zone.tsx +156 -0
  39. package/src/file-upload/file-preview-grid.tsx +39 -0
  40. package/src/file-upload/file-preview-item.tsx +175 -0
  41. package/src/file-upload/file-thumbnail.tsx +98 -0
  42. package/src/file-upload/file-upload-button.tsx +95 -0
  43. package/src/file-upload/index.ts +13 -0
  44. package/src/file-upload/use-file-upload.ts +371 -0
  45. package/src/filter-bar.tsx +125 -0
  46. package/src/filter-dropdown.tsx +289 -0
  47. package/src/floating-drawer.tsx +420 -0
  48. package/src/folder-card.tsx +188 -0
  49. package/src/folder-tree-picker.tsx +511 -0
  50. package/src/format.ts +117 -0
  51. package/src/frame-stack.tsx +73 -0
  52. package/src/hero-form-card.tsx +471 -0
  53. package/src/index.ts +372 -0
  54. package/src/input.tsx +130 -0
  55. package/src/kbd.tsx +41 -0
  56. package/src/lib/layout.ts +22 -0
  57. package/src/lib/utils.ts +6 -0
  58. package/src/markdown.tsx +214 -0
  59. package/src/menu-button.tsx +142 -0
  60. package/src/meta-previews/CreativeMedia.tsx +119 -0
  61. package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
  62. package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
  63. package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
  64. package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
  65. package/src/meta-previews/MetaFeedPreview.tsx +294 -0
  66. package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
  67. package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
  68. package/src/meta-previews/MetaReelPreview.tsx +750 -0
  69. package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
  70. package/src/meta-previews/MetaSearchPreview.tsx +113 -0
  71. package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
  72. package/src/meta-previews/ProfileAvatar.tsx +79 -0
  73. package/src/meta-previews/defaults.ts +27 -0
  74. package/src/meta-previews/index.ts +37 -0
  75. package/src/meta-previews/types.ts +219 -0
  76. package/src/metric-card.tsx +117 -0
  77. package/src/modal.tsx +131 -0
  78. package/src/option-tile.tsx +252 -0
  79. package/src/page-hero.tsx +78 -0
  80. package/src/page-shell.tsx +440 -0
  81. package/src/pagination.tsx +86 -0
  82. package/src/pill-button.tsx +103 -0
  83. package/src/profile-section.tsx +389 -0
  84. package/src/progress-bar.tsx +84 -0
  85. package/src/radio.tsx +172 -0
  86. package/src/range-slider.tsx +161 -0
  87. package/src/resizable.tsx +282 -0
  88. package/src/search-bar.tsx +81 -0
  89. package/src/segmented-pill.tsx +143 -0
  90. package/src/shell.tsx +464 -0
  91. package/src/slider.tsx +107 -0
  92. package/src/sortable-table.tsx +198 -0
  93. package/src/sortable.tsx +137 -0
  94. package/src/stat-row.tsx +132 -0
  95. package/src/stats-grid.tsx +38 -0
  96. package/src/step-loader.tsx +141 -0
  97. package/src/styles.css +562 -0
  98. package/src/tabs.tsx +143 -0
  99. package/src/tag-chip-input.tsx +394 -0
  100. package/src/textarea.tsx +39 -0
  101. package/src/toast.tsx +59 -0
  102. package/src/tokens.css +579 -0
@@ -0,0 +1,188 @@
1
+ /**
2
+ * FolderCard — drive-style folder tile.
3
+ *
4
+ * A horizontal tile: a colour-keyed folder-icon swatch, the folder
5
+ * name, an asset / subfolder count line, a hover-revealed selection
6
+ * checkbox, and an optional hover-revealed menu slot. Clicking the
7
+ * body opens the folder.
8
+ *
9
+ * Promoted to @lovett/ui in ADR-035 (Assets lens redesign). It is a
10
+ * primitive — not lens-local — because a folder card is a card
11
+ * surface and needs the internal surface tokens (`--bg-card`,
12
+ * `--border-card`); CLAUDE.md §5 says that is exactly when a
13
+ * component belongs in `packages/ui/`. Reusable by the v1.1 Swipes /
14
+ * Collections lenses (PRD §5.6) without a fork.
15
+ *
16
+ * Folder colour is a KEY into the `--folder-*` token palette
17
+ * (tokens.css — ADR-035 Decision 6), never a hex. The primitive maps
18
+ * the key to `rgb(var(--folder-<key>))`.
19
+ */
20
+
21
+ import { useState, type KeyboardEvent, type ReactNode } from 'react'
22
+ import { Check, Folder } from 'lucide-react'
23
+ import { cn } from './lib/utils'
24
+
25
+ export type FolderColorKey =
26
+ | 'red'
27
+ | 'amber'
28
+ | 'emerald'
29
+ | 'teal'
30
+ | 'blue'
31
+ | 'violet'
32
+ | 'pink'
33
+ | 'slate'
34
+ | 'ink'
35
+
36
+ /** All folder colour keys, in palette order — for swatch pickers. */
37
+ export const FOLDER_COLOR_KEYS: FolderColorKey[] = [
38
+ 'red',
39
+ 'amber',
40
+ 'emerald',
41
+ 'teal',
42
+ 'blue',
43
+ 'violet',
44
+ 'pink',
45
+ 'slate',
46
+ 'ink',
47
+ ]
48
+
49
+ /** Resolve a colour key to its `rgb(var(--folder-*))` CSS value. */
50
+ export function folderColorVar(key: FolderColorKey): string {
51
+ return `rgb(var(--folder-${key}))`
52
+ }
53
+
54
+ export interface FolderCardProps {
55
+ name: string
56
+ colorKey: FolderColorKey
57
+ assetCount: number
58
+ /** Subfolder count — omitted or 0 → not shown. */
59
+ folderCount?: number
60
+ selected?: boolean
61
+ /** When true, a selection checkbox reveals on hover and stays
62
+ * visible while selected. */
63
+ selectable?: boolean
64
+ onOpen: () => void
65
+ onSelect?: () => void
66
+ /** Optional menu affordance (e.g. a kebab trigger), revealed on
67
+ * hover at the row end. */
68
+ menuSlot?: ReactNode
69
+ className?: string
70
+ }
71
+
72
+ export function FolderCard({
73
+ name,
74
+ colorKey,
75
+ assetCount,
76
+ folderCount = 0,
77
+ selected = false,
78
+ selectable = false,
79
+ onOpen,
80
+ onSelect,
81
+ menuSlot,
82
+ className,
83
+ }: FolderCardProps) {
84
+ const [hover, setHover] = useState(false)
85
+ const color = folderColorVar(colorKey)
86
+ const showCheckbox = selectable && (hover || selected)
87
+
88
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
89
+ if (e.key === 'Enter' || e.key === ' ') {
90
+ e.preventDefault()
91
+ onOpen()
92
+ }
93
+ }
94
+
95
+ return (
96
+ <div
97
+ role="button"
98
+ tabIndex={0}
99
+ onClick={onOpen}
100
+ onKeyDown={onKeyDown}
101
+ onMouseEnter={() => setHover(true)}
102
+ onMouseLeave={() => setHover(false)}
103
+ className={cn(
104
+ 'group/folder relative flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-shadow text-left',
105
+ className,
106
+ )}
107
+ style={{
108
+ background: selected
109
+ ? 'rgb(var(--accent-subtle))'
110
+ : 'rgb(var(--bg-card))',
111
+ border: `1px solid ${
112
+ selected ? 'rgb(var(--accent))' : 'rgb(var(--border-card))'
113
+ }`,
114
+ borderRadius: 'var(--radius-md)',
115
+ boxShadow: hover ? 'var(--shadow-sm)' : 'none',
116
+ }}
117
+ >
118
+ {/* Selection checkbox — top-left, revealed on hover / selected. */}
119
+ {showCheckbox && (
120
+ <button
121
+ type="button"
122
+ onClick={(e) => {
123
+ e.stopPropagation()
124
+ onSelect?.()
125
+ }}
126
+ aria-label={selected ? `Deselect ${name}` : `Select ${name}`}
127
+ aria-pressed={selected}
128
+ className="absolute top-2 left-2 z-[1] inline-flex h-[18px] w-[18px] items-center justify-center cursor-pointer text-white"
129
+ style={{
130
+ borderRadius: 'var(--radius-xs)',
131
+ background: selected
132
+ ? 'rgb(var(--accent))'
133
+ : 'rgb(var(--bg-card))',
134
+ border: `1.5px solid ${
135
+ selected ? 'rgb(var(--accent))' : 'rgb(var(--border-strong))'
136
+ }`,
137
+ }}
138
+ >
139
+ {selected && <Check className="h-3 w-3" strokeWidth={3} />}
140
+ </button>
141
+ )}
142
+
143
+ {/* Colour swatch + folder icon. */}
144
+ <span
145
+ className="inline-flex h-[38px] w-[38px] shrink-0 items-center justify-center"
146
+ style={{
147
+ borderRadius: 'var(--radius-sm)',
148
+ background: `rgb(var(--folder-${colorKey}) / 0.12)`,
149
+ color,
150
+ }}
151
+ >
152
+ <Folder className="h-5 w-5" strokeWidth={1.6} aria-hidden="true" />
153
+ </span>
154
+
155
+ {/* Name + count. */}
156
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
157
+ <span
158
+ className="truncate text-[13.5px] font-semibold"
159
+ style={{ color: 'rgb(var(--foreground))' }}
160
+ >
161
+ {name}
162
+ </span>
163
+ <span
164
+ className="text-[11.5px] tabular-nums"
165
+ style={{ color: 'rgb(var(--text-tertiary))' }}
166
+ >
167
+ {assetCount} {assetCount === 1 ? 'asset' : 'assets'}
168
+ {folderCount > 0 && (
169
+ <>
170
+ {' · '}
171
+ {folderCount} {folderCount === 1 ? 'folder' : 'folders'}
172
+ </>
173
+ )}
174
+ </span>
175
+ </div>
176
+
177
+ {/* Menu slot — revealed on hover. */}
178
+ {menuSlot && (
179
+ <span
180
+ className="shrink-0 opacity-0 transition-opacity group-hover/folder:opacity-100"
181
+ onClick={(e) => e.stopPropagation()}
182
+ >
183
+ {menuSlot}
184
+ </span>
185
+ )}
186
+ </div>
187
+ )
188
+ }
@@ -0,0 +1,511 @@
1
+ /**
2
+ * FolderTreePicker — dropdown picker for a nestable folder tree.
3
+ *
4
+ * Behavior:
5
+ * • Trigger renders the selected folder's name (with parent breadcrumb
6
+ * when nested) or a placeholder when none.
7
+ * • Popover shows a "(No folder)" option + DFS-ordered tree with
8
+ * indent by depth.
9
+ * • Optional inline "+ New folder" path — when the consumer passes
10
+ * `onCreateFolder`, the popover footer exposes a create form whose
11
+ * parent defaults to the currently selected folder (or root).
12
+ *
13
+ * Generic — accepts a `folders: Array<{ id, name, parentId }>` shape so
14
+ * any folder-like data structure can use it. First consumer: Keywords
15
+ * lens NewSetPage / NewGroupPage.
16
+ *
17
+ * Token discipline: tokens only (no hex). Trigger inherits the same
18
+ * `.btn .btn-secondary` chrome as our other dropdown triggers for
19
+ * visual consistency.
20
+ */
21
+
22
+ import {
23
+ useCallback,
24
+ useEffect,
25
+ useId,
26
+ useLayoutEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ type ChangeEvent,
31
+ type KeyboardEvent,
32
+ } from 'react'
33
+ import { createPortal } from 'react-dom'
34
+ import { Check, ChevronDown, FolderClosed, FolderPlus, X } from 'lucide-react'
35
+ import { cn } from './lib/utils'
36
+
37
+ export interface FolderTreeNode {
38
+ id: string
39
+ name: string
40
+ parentId: string | null
41
+ }
42
+
43
+ export interface FolderTreePickerProps {
44
+ /** Flat list of folder nodes (parentId references id). */
45
+ folders: FolderTreeNode[]
46
+ /** Currently selected folder id, or null for "no folder" / root. */
47
+ value: string | null
48
+ onChange: (next: string | null) => void
49
+ /** Optional inline-create handler. When omitted, the create UI is
50
+ * hidden — the picker is read-only over the given folders. The
51
+ * returned id is selected on success. */
52
+ onCreateFolder?: (input: {
53
+ name: string
54
+ parentId: string | null
55
+ }) => Promise<{ id: string }>
56
+ /** Placeholder when no folder is selected. */
57
+ placeholder?: string
58
+ /** Disable everything. */
59
+ disabled?: boolean
60
+ /** id for the trigger — supports label-for binding. */
61
+ id?: string
62
+ /** Optional className for the trigger button. */
63
+ className?: string
64
+ /** Apply error styling to the trigger. */
65
+ error?: boolean
66
+ }
67
+
68
+ interface OrderedNode extends FolderTreeNode {
69
+ depth: number
70
+ }
71
+
72
+ function buildOrderedTree(folders: FolderTreeNode[]): OrderedNode[] {
73
+ // Children index keyed by parentId (null bucket = root).
74
+ const byParent = new Map<string | null, FolderTreeNode[]>()
75
+ for (const f of folders) {
76
+ const list = byParent.get(f.parentId) ?? []
77
+ list.push(f)
78
+ byParent.set(f.parentId, list)
79
+ }
80
+ for (const list of byParent.values()) {
81
+ list.sort((a, b) => a.name.localeCompare(b.name))
82
+ }
83
+ const out: OrderedNode[] = []
84
+ const walk = (parentId: string | null, depth: number) => {
85
+ const children = byParent.get(parentId) ?? []
86
+ for (const child of children) {
87
+ out.push({ ...child, depth })
88
+ walk(child.id, depth + 1)
89
+ }
90
+ }
91
+ walk(null, 0)
92
+ return out
93
+ }
94
+
95
+ function buildBreadcrumb(
96
+ folders: FolderTreeNode[],
97
+ id: string | null,
98
+ ): string {
99
+ if (!id) return ''
100
+ const byId = new Map(folders.map((f) => [f.id, f]))
101
+ const chain: string[] = []
102
+ let cur: FolderTreeNode | undefined = byId.get(id)
103
+ while (cur) {
104
+ chain.unshift(cur.name)
105
+ cur = cur.parentId ? byId.get(cur.parentId) : undefined
106
+ }
107
+ return chain.join(' / ')
108
+ }
109
+
110
+ export function FolderTreePicker({
111
+ folders,
112
+ value,
113
+ onChange,
114
+ onCreateFolder,
115
+ placeholder = 'No folder',
116
+ disabled,
117
+ id,
118
+ className,
119
+ error,
120
+ }: FolderTreePickerProps) {
121
+ const [open, setOpen] = useState(false)
122
+ const [creating, setCreating] = useState(false)
123
+ const [draft, setDraft] = useState('')
124
+ const [pending, setPending] = useState(false)
125
+ const [createError, setCreateError] = useState<string | null>(null)
126
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
127
+ const popoverRef = useRef<HTMLDivElement | null>(null)
128
+ const draftInputRef = useRef<HTMLInputElement | null>(null)
129
+ const generatedId = useId()
130
+ const popoverId = `folder-picker-${generatedId.replace(/:/g, '')}`
131
+
132
+ const orderedTree = useMemo(() => buildOrderedTree(folders), [folders])
133
+ const selectedLabel = useMemo(
134
+ () => buildBreadcrumb(folders, value),
135
+ [folders, value],
136
+ )
137
+
138
+ const closeAndReset = useCallback(() => {
139
+ setOpen(false)
140
+ setCreating(false)
141
+ setDraft('')
142
+ setCreateError(null)
143
+ }, [])
144
+
145
+ // Outside-click + Escape.
146
+ useEffect(() => {
147
+ if (!open) return
148
+ const onPointerDown = (event: PointerEvent) => {
149
+ const target = event.target as Node | null
150
+ if (!target) return
151
+ if (triggerRef.current?.contains(target)) return
152
+ if (popoverRef.current?.contains(target)) return
153
+ closeAndReset()
154
+ }
155
+ const onKeyDown = (event: KeyboardEvent | globalThis.KeyboardEvent) => {
156
+ if (event.key === 'Escape') {
157
+ event.stopPropagation()
158
+ closeAndReset()
159
+ triggerRef.current?.focus()
160
+ }
161
+ }
162
+ document.addEventListener('pointerdown', onPointerDown)
163
+ document.addEventListener('keydown', onKeyDown as EventListener)
164
+ return () => {
165
+ document.removeEventListener('pointerdown', onPointerDown)
166
+ document.removeEventListener('keydown', onKeyDown as EventListener)
167
+ }
168
+ }, [open, closeAndReset])
169
+
170
+ // Position the popover under the trigger. Same two-phase approach as
171
+ // <DropdownMenuContent>: render off-screen at first, measure, then
172
+ // reposition + reveal.
173
+ const [popoverPos, setPopoverPos] = useState<{
174
+ left: number
175
+ top: number
176
+ width: number
177
+ } | null>(null)
178
+ useLayoutEffect(() => {
179
+ if (!open) {
180
+ setPopoverPos(null)
181
+ return
182
+ }
183
+ const measure = () => {
184
+ const trig = triggerRef.current
185
+ if (!trig) return
186
+ const rect = trig.getBoundingClientRect()
187
+ // Width: at least 240px, capped at viewport.
188
+ const width = Math.max(
189
+ 240,
190
+ Math.min(rect.width, window.innerWidth - 16),
191
+ )
192
+ const gutter = 8
193
+ let left = rect.left
194
+ const maxLeft = window.innerWidth - width - gutter
195
+ left = Math.max(gutter, Math.min(left, maxLeft))
196
+ const top = rect.bottom + 6
197
+ setPopoverPos({ left, top, width })
198
+ }
199
+ measure()
200
+ window.addEventListener('resize', measure)
201
+ window.addEventListener('scroll', measure, true)
202
+ return () => {
203
+ window.removeEventListener('resize', measure)
204
+ window.removeEventListener('scroll', measure, true)
205
+ }
206
+ }, [open])
207
+
208
+ // Focus the draft input when the create row opens.
209
+ useEffect(() => {
210
+ if (creating) {
211
+ // Delay one frame so the input renders before focus.
212
+ const handle = requestAnimationFrame(() => {
213
+ draftInputRef.current?.focus()
214
+ })
215
+ return () => cancelAnimationFrame(handle)
216
+ }
217
+ return undefined
218
+ }, [creating])
219
+
220
+ const handleCreate = async () => {
221
+ if (!onCreateFolder) return
222
+ const name = draft.trim()
223
+ if (!name) return
224
+ setPending(true)
225
+ setCreateError(null)
226
+ try {
227
+ const created = await onCreateFolder({
228
+ name,
229
+ parentId: value,
230
+ })
231
+ onChange(created.id)
232
+ closeAndReset()
233
+ } catch (err) {
234
+ setCreateError(
235
+ err instanceof Error ? err.message : 'Could not create folder.',
236
+ )
237
+ } finally {
238
+ setPending(false)
239
+ }
240
+ }
241
+
242
+ const handleDraftKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
243
+ if (event.key === 'Enter') {
244
+ event.preventDefault()
245
+ handleCreate()
246
+ }
247
+ if (event.key === 'Escape') {
248
+ event.preventDefault()
249
+ setCreating(false)
250
+ setDraft('')
251
+ setCreateError(null)
252
+ }
253
+ }
254
+
255
+ const isPositioned = popoverPos !== null
256
+
257
+ return (
258
+ <>
259
+ <button
260
+ ref={triggerRef}
261
+ id={id}
262
+ type="button"
263
+ aria-haspopup="listbox"
264
+ aria-expanded={open}
265
+ aria-controls={open ? popoverId : undefined}
266
+ disabled={disabled}
267
+ onClick={() => setOpen((o) => !o)}
268
+ className={cn(
269
+ 'btn btn-secondary justify-between w-full',
270
+ error && 'is-error',
271
+ className,
272
+ )}
273
+ style={{
274
+ borderColor: error ? 'rgb(var(--ring-error))' : undefined,
275
+ }}
276
+ >
277
+ <span className="inline-flex items-center gap-2 min-w-0">
278
+ <FolderClosed
279
+ className="h-4 w-4 shrink-0"
280
+ style={{
281
+ color: value
282
+ ? 'rgb(var(--text-secondary))'
283
+ : 'rgb(var(--text-tertiary))',
284
+ }}
285
+ aria-hidden="true"
286
+ />
287
+ <span
288
+ className="truncate"
289
+ style={{
290
+ color: value
291
+ ? 'rgb(var(--foreground))'
292
+ : 'rgb(var(--text-tertiary))',
293
+ }}
294
+ >
295
+ {selectedLabel || placeholder}
296
+ </span>
297
+ </span>
298
+ <ChevronDown
299
+ className="h-4 w-4 shrink-0"
300
+ aria-hidden="true"
301
+ style={{ color: 'rgb(var(--text-muted))' }}
302
+ />
303
+ </button>
304
+
305
+ {open && typeof document !== 'undefined'
306
+ ? createPortal(
307
+ <div
308
+ ref={popoverRef}
309
+ id={popoverId}
310
+ role="listbox"
311
+ className="fixed z-[100] overflow-hidden flex flex-col"
312
+ style={{
313
+ left: popoverPos?.left ?? -99999,
314
+ top: popoverPos?.top ?? -99999,
315
+ width: popoverPos?.width,
316
+ opacity: isPositioned ? undefined : 0,
317
+ visibility: isPositioned ? 'visible' : 'hidden',
318
+ pointerEvents: isPositioned ? undefined : 'none',
319
+ background: 'rgb(var(--popover))',
320
+ color: 'rgb(var(--popover-foreground))',
321
+ border: '1px solid rgb(var(--border))',
322
+ borderRadius: 'var(--radius-md)',
323
+ boxShadow:
324
+ 'var(--shadow-lg, 0 10px 32px rgba(0,0,0,0.18))',
325
+ maxHeight: 'min(60vh, 28rem)',
326
+ }}
327
+ >
328
+ <div className="overflow-y-auto flex-1 p-1">
329
+ <PickerOption
330
+ label={placeholder}
331
+ selected={value === null}
332
+ depth={0}
333
+ showIcon={false}
334
+ onClick={() => {
335
+ onChange(null)
336
+ closeAndReset()
337
+ }}
338
+ />
339
+ {orderedTree.length > 0 && (
340
+ <div
341
+ className="my-1 h-px"
342
+ style={{ background: 'rgb(var(--border))' }}
343
+ />
344
+ )}
345
+ {orderedTree.map((node) => (
346
+ <PickerOption
347
+ key={node.id}
348
+ label={node.name}
349
+ selected={value === node.id}
350
+ depth={node.depth}
351
+ showIcon
352
+ onClick={() => {
353
+ onChange(node.id)
354
+ closeAndReset()
355
+ }}
356
+ />
357
+ ))}
358
+ </div>
359
+
360
+ {onCreateFolder && (
361
+ <div
362
+ className="p-1 shrink-0"
363
+ style={{
364
+ borderTop: '1px solid rgb(var(--border))',
365
+ background: 'rgb(var(--surface-overlay-soft))',
366
+ }}
367
+ >
368
+ {creating ? (
369
+ <div className="flex flex-col gap-1 px-1.5 py-1">
370
+ <div className="flex items-center gap-1.5">
371
+ <FolderPlus
372
+ className="h-3.5 w-3.5 shrink-0"
373
+ style={{ color: 'rgb(var(--text-muted))' }}
374
+ aria-hidden="true"
375
+ />
376
+ <input
377
+ ref={draftInputRef}
378
+ type="text"
379
+ value={draft}
380
+ onChange={(e: ChangeEvent<HTMLInputElement>) =>
381
+ setDraft(e.target.value)
382
+ }
383
+ onKeyDown={handleDraftKeyDown}
384
+ placeholder={
385
+ value
386
+ ? 'New sub-folder name'
387
+ : 'New folder name'
388
+ }
389
+ maxLength={80}
390
+ disabled={pending}
391
+ className="flex-1 min-w-0 bg-transparent text-[13px] outline-none"
392
+ style={{ color: 'rgb(var(--foreground))' }}
393
+ />
394
+ <button
395
+ type="button"
396
+ aria-label="Cancel new folder"
397
+ onClick={() => {
398
+ setCreating(false)
399
+ setDraft('')
400
+ setCreateError(null)
401
+ }}
402
+ className="inline-flex h-5 w-5 items-center justify-center rounded-[var(--radius-xs)] hover:bg-[rgb(var(--surface-overlay))]"
403
+ style={{ color: 'rgb(var(--text-muted))' }}
404
+ >
405
+ <X className="h-3 w-3" aria-hidden="true" />
406
+ </button>
407
+ <button
408
+ type="button"
409
+ onClick={handleCreate}
410
+ disabled={pending || draft.trim().length === 0}
411
+ className="btn btn-primary size-sm"
412
+ >
413
+ Create
414
+ </button>
415
+ </div>
416
+ {value && (
417
+ <span
418
+ className="text-[11px] pl-5"
419
+ style={{ color: 'rgb(var(--text-tertiary))' }}
420
+ >
421
+ Created inside {selectedLabel}
422
+ </span>
423
+ )}
424
+ {createError && (
425
+ <span
426
+ role="alert"
427
+ className="text-[11px] pl-5"
428
+ style={{ color: 'rgb(var(--destructive))' }}
429
+ >
430
+ {createError}
431
+ </span>
432
+ )}
433
+ </div>
434
+ ) : (
435
+ <button
436
+ type="button"
437
+ onClick={() => setCreating(true)}
438
+ className="w-full flex items-center gap-2 px-2 py-1.5 text-left text-[13px] rounded-[var(--radius-xs)] hover:bg-[rgb(var(--surface-overlay))]"
439
+ style={{ color: 'rgb(var(--foreground))' }}
440
+ >
441
+ <FolderPlus
442
+ className="h-3.5 w-3.5 shrink-0"
443
+ style={{ color: 'rgb(var(--text-muted))' }}
444
+ aria-hidden="true"
445
+ />
446
+ <span>New folder</span>
447
+ </button>
448
+ )}
449
+ </div>
450
+ )}
451
+ </div>,
452
+ document.body,
453
+ )
454
+ : null}
455
+ </>
456
+ )
457
+ }
458
+
459
+ interface PickerOptionProps {
460
+ label: string
461
+ selected: boolean
462
+ depth: number
463
+ showIcon: boolean
464
+ onClick: () => void
465
+ }
466
+
467
+ function PickerOption({
468
+ label,
469
+ selected,
470
+ depth,
471
+ showIcon,
472
+ onClick,
473
+ }: PickerOptionProps) {
474
+ return (
475
+ <button
476
+ type="button"
477
+ role="option"
478
+ aria-selected={selected}
479
+ onClick={onClick}
480
+ className={cn(
481
+ 'w-full flex items-center gap-2 px-2 py-1.5 text-left text-[13px]',
482
+ 'rounded-[var(--radius-xs)] cursor-default',
483
+ selected
484
+ ? 'bg-[rgb(var(--surface-overlay))]'
485
+ : 'hover:bg-[rgb(var(--surface-overlay-soft))]',
486
+ )}
487
+ style={{
488
+ color: 'rgb(var(--foreground))',
489
+ paddingLeft: `${0.5 + depth * 0.875}rem`,
490
+ }}
491
+ >
492
+ {showIcon ? (
493
+ <FolderClosed
494
+ className="h-3.5 w-3.5 shrink-0"
495
+ style={{ color: 'rgb(var(--text-muted))' }}
496
+ aria-hidden="true"
497
+ />
498
+ ) : (
499
+ <span className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
500
+ )}
501
+ <span className="truncate flex-1">{label}</span>
502
+ {selected && (
503
+ <Check
504
+ className="h-3.5 w-3.5 shrink-0"
505
+ style={{ color: 'rgb(var(--accent))' }}
506
+ aria-hidden="true"
507
+ />
508
+ )}
509
+ </button>
510
+ )
511
+ }