@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,450 @@
1
+ /**
2
+ * DropdownMenu — workspace-native dropdown menu primitive.
3
+ *
4
+ * Inspired by gray-ui-csm `components/ui/dropdown-menu.tsx` (MIT,
5
+ * https://github.com/Jason-uxui/gray-ui-csm/blob/baf8346ac0c1160282e0b173de1bffc594d9c9c3/components/ui/dropdown-menu.tsx)
6
+ * — see packages/ui/licenses/gray-ui-csm-LICENSE.txt.
7
+ *
8
+ * BUILD_FRESH per ADR-025 DEVIATION-3: source ships on
9
+ * `@base-ui/react/menu`, which is NOT in CLAUDE.md §1 allowed deps.
10
+ * The compound-component surface (Root / Trigger / Content / Item /
11
+ * CheckboxItem / Separator / Label / Group) carries over; the
12
+ * implementation is a lightweight React Context + Portal + outside-
13
+ * click + Escape handler.
14
+ *
15
+ * Trigger styling per ADR-026 D4 (DQ-4 LIFT): the default trigger
16
+ * inherits the secondary Button visual (rounded-xl, `--bg-card` fill,
17
+ * 1px `--border`). Consumers can pass `asChild`-equivalent custom
18
+ * trigger content; the wrapper applies the button-style classes
19
+ * unless `unstyled` is set.
20
+ *
21
+ * Workspace ADR: ADR-025 (port) + ADR-026 D4 (trigger spec)
22
+ * Ported 2026-05-18
23
+ */
24
+
25
+ import {
26
+ createContext,
27
+ useCallback,
28
+ useContext,
29
+ useEffect,
30
+ useId,
31
+ useLayoutEffect,
32
+ useMemo,
33
+ useRef,
34
+ useState,
35
+ type ButtonHTMLAttributes,
36
+ type HTMLAttributes,
37
+ type ReactNode,
38
+ } from 'react'
39
+ import { createPortal } from 'react-dom'
40
+ import { Check } from 'lucide-react'
41
+ import { cn } from './lib/utils'
42
+
43
+ type Align = 'start' | 'center' | 'end'
44
+ type Side = 'top' | 'bottom'
45
+
46
+ interface DropdownMenuContextValue {
47
+ open: boolean
48
+ setOpen: (next: boolean) => void
49
+ triggerRef: React.RefObject<HTMLButtonElement | null>
50
+ contentId: string
51
+ }
52
+
53
+ const DropdownMenuCtx = createContext<DropdownMenuContextValue | null>(null)
54
+
55
+ function useDropdownMenuCtx(consumer: string): DropdownMenuContextValue {
56
+ const ctx = useContext(DropdownMenuCtx)
57
+ if (!ctx) {
58
+ throw new Error(
59
+ `${consumer} must be rendered inside <DropdownMenu>. ` +
60
+ `Wrap your trigger + content with the DropdownMenu root component.`,
61
+ )
62
+ }
63
+ return ctx
64
+ }
65
+
66
+ export interface DropdownMenuProps {
67
+ /** Controlled open state. Omit for internal state. */
68
+ open?: boolean
69
+ /** Fires whenever open transitions. */
70
+ onOpenChange?: (open: boolean) => void
71
+ defaultOpen?: boolean
72
+ children: ReactNode
73
+ }
74
+
75
+ export function DropdownMenu({
76
+ open: controlledOpen,
77
+ onOpenChange,
78
+ defaultOpen,
79
+ children,
80
+ }: DropdownMenuProps) {
81
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(
82
+ Boolean(defaultOpen),
83
+ )
84
+ const isControlled = controlledOpen !== undefined
85
+ const open = isControlled ? Boolean(controlledOpen) : uncontrolledOpen
86
+ const triggerRef = useRef<HTMLButtonElement | null>(null)
87
+ const generatedId = useId()
88
+ const contentId = `dropdown-${generatedId.replace(/:/g, '')}`
89
+
90
+ const setOpen = useCallback(
91
+ (next: boolean) => {
92
+ if (!isControlled) setUncontrolledOpen(next)
93
+ onOpenChange?.(next)
94
+ },
95
+ [isControlled, onOpenChange],
96
+ )
97
+
98
+ const value = useMemo<DropdownMenuContextValue>(
99
+ () => ({ open, setOpen, triggerRef, contentId }),
100
+ [open, setOpen, contentId],
101
+ )
102
+
103
+ return (
104
+ <DropdownMenuCtx.Provider value={value}>
105
+ {children}
106
+ </DropdownMenuCtx.Provider>
107
+ )
108
+ }
109
+
110
+ export interface DropdownMenuTriggerProps
111
+ extends ButtonHTMLAttributes<HTMLButtonElement> {
112
+ /** Skip the default secondary-Button styling. Useful when wrapping
113
+ * an existing Button primitive. */
114
+ unstyled?: boolean
115
+ }
116
+
117
+ /**
118
+ * Default trigger styled per ADR-026 D4 (DQ-4) — inherits the
119
+ * secondary Button visual (rounded-xl + `--bg-card` fill + 1px
120
+ * `--border`). Set `unstyled` to wrap your own button shape.
121
+ */
122
+ export function DropdownMenuTrigger({
123
+ unstyled,
124
+ className,
125
+ onClick,
126
+ children,
127
+ ...rest
128
+ }: DropdownMenuTriggerProps) {
129
+ const { open, setOpen, triggerRef, contentId } =
130
+ useDropdownMenuCtx('DropdownMenuTrigger')
131
+
132
+ return (
133
+ <button
134
+ ref={triggerRef}
135
+ type="button"
136
+ aria-haspopup="menu"
137
+ aria-expanded={open}
138
+ aria-controls={open ? contentId : undefined}
139
+ className={cn(
140
+ !unstyled && 'btn btn-secondary',
141
+ className,
142
+ )}
143
+ onClick={(event) => {
144
+ onClick?.(event)
145
+ if (!event.defaultPrevented) setOpen(!open)
146
+ }}
147
+ {...rest}
148
+ >
149
+ {children}
150
+ </button>
151
+ )
152
+ }
153
+
154
+ export interface DropdownMenuContentProps
155
+ extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
156
+ children: ReactNode
157
+ /** Horizontal alignment relative to the trigger. Default `start`. */
158
+ align?: Align
159
+ /** Pixel offset along the alignment axis. Default 0. */
160
+ alignOffset?: number
161
+ /** Place above (`top`) or below (`bottom`) the trigger. Default `bottom`. */
162
+ side?: Side
163
+ /** Pixel offset along the side axis. Default 6. */
164
+ sideOffset?: number
165
+ }
166
+
167
+ export function DropdownMenuContent({
168
+ children,
169
+ align = 'start',
170
+ alignOffset = 0,
171
+ side = 'bottom',
172
+ sideOffset = 6,
173
+ className,
174
+ style,
175
+ ...rest
176
+ }: DropdownMenuContentProps) {
177
+ const { open, setOpen, triggerRef, contentId } =
178
+ useDropdownMenuCtx('DropdownMenuContent')
179
+ const contentRef = useRef<HTMLDivElement | null>(null)
180
+ const [position, setPosition] = useState<{ left: number; top: number } | null>(
181
+ null,
182
+ )
183
+
184
+ // Position relative to the trigger viewport rect. We measure on
185
+ // open + on layout changes (scroll / resize) so the panel tracks.
186
+ useLayoutEffect(() => {
187
+ if (!open) {
188
+ setPosition(null)
189
+ return
190
+ }
191
+ const measure = () => {
192
+ const triggerEl = triggerRef.current
193
+ const contentEl = contentRef.current
194
+ if (!triggerEl || !contentEl) return
195
+ const triggerRect = triggerEl.getBoundingClientRect()
196
+ const contentRect = contentEl.getBoundingClientRect()
197
+
198
+ let left = triggerRect.left + alignOffset
199
+ if (align === 'end') left = triggerRect.right - contentRect.width - alignOffset
200
+ else if (align === 'center')
201
+ left =
202
+ triggerRect.left +
203
+ triggerRect.width / 2 -
204
+ contentRect.width / 2 +
205
+ alignOffset
206
+
207
+ let top =
208
+ side === 'bottom'
209
+ ? triggerRect.bottom + sideOffset
210
+ : triggerRect.top - contentRect.height - sideOffset
211
+
212
+ // Keep on-screen — clamp to viewport with an 8px gutter.
213
+ const gutter = 8
214
+ const maxLeft = window.innerWidth - contentRect.width - gutter
215
+ const maxTop = window.innerHeight - contentRect.height - gutter
216
+ left = Math.max(gutter, Math.min(left, maxLeft))
217
+ top = Math.max(gutter, Math.min(top, maxTop))
218
+
219
+ setPosition({ left, top })
220
+ }
221
+
222
+ measure()
223
+ window.addEventListener('resize', measure)
224
+ window.addEventListener('scroll', measure, true)
225
+ return () => {
226
+ window.removeEventListener('resize', measure)
227
+ window.removeEventListener('scroll', measure, true)
228
+ }
229
+ }, [open, align, alignOffset, side, sideOffset, triggerRef])
230
+
231
+ // Outside-click + Escape dismissal.
232
+ useEffect(() => {
233
+ if (!open) return
234
+ const onPointerDown = (event: PointerEvent) => {
235
+ const target = event.target as Node | null
236
+ if (!target) return
237
+ if (contentRef.current?.contains(target)) return
238
+ if (triggerRef.current?.contains(target)) return
239
+ setOpen(false)
240
+ }
241
+ const onKeyDown = (event: KeyboardEvent) => {
242
+ if (event.key === 'Escape') {
243
+ event.stopPropagation()
244
+ setOpen(false)
245
+ triggerRef.current?.focus()
246
+ }
247
+ }
248
+ document.addEventListener('pointerdown', onPointerDown)
249
+ document.addEventListener('keydown', onKeyDown)
250
+ return () => {
251
+ document.removeEventListener('pointerdown', onPointerDown)
252
+ document.removeEventListener('keydown', onKeyDown)
253
+ }
254
+ }, [open, setOpen, triggerRef])
255
+
256
+ if (!open || typeof document === 'undefined') return null
257
+
258
+ // Two-phase render: first paint measures the content invisibly at
259
+ // (0,0) with opacity:0, useLayoutEffect computes the real position,
260
+ // and the second paint applies the correct coords + entrance animation.
261
+ // The animation classes are gated behind `isPositioned` so the
262
+ // entrance never fires from the unpositioned location — that's what
263
+ // caused the "flash at top of viewport then jump under the trigger"
264
+ // glitch.
265
+ const isPositioned = position !== null
266
+
267
+ return createPortal(
268
+ <div
269
+ ref={contentRef}
270
+ id={contentId}
271
+ role="menu"
272
+ data-slot="dropdown-menu-content"
273
+ data-state="open"
274
+ data-positioned={isPositioned ? 'true' : 'false'}
275
+ className={cn(
276
+ 'fixed z-[100] min-w-48 max-w-[min(28rem,calc(100vw-16px))]',
277
+ 'p-1.5 overflow-y-auto',
278
+ 'rounded-[var(--radius-lg)]',
279
+ isPositioned && 'animate-in fade-in-0 zoom-in-95 duration-100',
280
+ className,
281
+ )}
282
+ style={{
283
+ // Anchor the unpositioned phase WAY off-screen as a defense
284
+ // against any browser/renderer that doesn't fully respect
285
+ // visibility:hidden during a same-frame double-render. If the
286
+ // first paint slips through, it's at (-99999, -99999), not at
287
+ // viewport (0, 0) where a left-edge flash would be visible.
288
+ left: position?.left ?? -99999,
289
+ top: position?.top ?? -99999,
290
+ opacity: isPositioned ? undefined : 0,
291
+ pointerEvents: isPositioned ? undefined : 'none',
292
+ visibility: isPositioned ? 'visible' : 'hidden',
293
+ background: 'rgb(var(--popover))',
294
+ color: 'rgb(var(--popover-foreground))',
295
+ border: '1px solid rgb(var(--border))',
296
+ boxShadow: 'var(--shadow-lg, 0 10px 32px rgba(0,0,0,0.18))',
297
+ ...style,
298
+ }}
299
+ {...rest}
300
+ >
301
+ {children}
302
+ </div>,
303
+ document.body,
304
+ )
305
+ }
306
+
307
+ export interface DropdownMenuItemProps
308
+ extends ButtonHTMLAttributes<HTMLButtonElement> {
309
+ variant?: 'default' | 'destructive'
310
+ /** Pad-left compensation so items with no leading icon align with
311
+ * items that have one. */
312
+ inset?: boolean
313
+ }
314
+
315
+ export function DropdownMenuItem({
316
+ variant = 'default',
317
+ inset,
318
+ className,
319
+ onClick,
320
+ children,
321
+ ...rest
322
+ }: DropdownMenuItemProps) {
323
+ const { setOpen } = useDropdownMenuCtx('DropdownMenuItem')
324
+ return (
325
+ <button
326
+ role="menuitem"
327
+ type="button"
328
+ data-slot="dropdown-menu-item"
329
+ data-variant={variant}
330
+ className={cn(
331
+ 'group relative flex w-full cursor-default items-center gap-2.5',
332
+ 'rounded-[var(--radius-md)] px-3 py-2 text-sm font-medium',
333
+ 'outline-none text-left',
334
+ 'transition-colors',
335
+ 'hover:bg-[rgb(var(--surface-overlay))]',
336
+ 'focus-visible:bg-[rgb(var(--surface-overlay))]',
337
+ 'disabled:pointer-events-none disabled:opacity-50',
338
+ inset && 'pl-9',
339
+ variant === 'destructive' && 'text-[rgb(var(--destructive))]',
340
+ className,
341
+ )}
342
+ onClick={(event) => {
343
+ onClick?.(event)
344
+ if (!event.defaultPrevented) setOpen(false)
345
+ }}
346
+ {...rest}
347
+ >
348
+ {children}
349
+ </button>
350
+ )
351
+ }
352
+
353
+ export interface DropdownMenuCheckboxItemProps
354
+ extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
355
+ checked?: boolean
356
+ onCheckedChange?: (checked: boolean) => void
357
+ }
358
+
359
+ export function DropdownMenuCheckboxItem({
360
+ checked,
361
+ onCheckedChange,
362
+ className,
363
+ children,
364
+ onClick,
365
+ disabled,
366
+ ...rest
367
+ }: DropdownMenuCheckboxItemProps) {
368
+ return (
369
+ <button
370
+ role="menuitemcheckbox"
371
+ type="button"
372
+ data-slot="dropdown-menu-checkbox-item"
373
+ aria-checked={Boolean(checked)}
374
+ disabled={disabled}
375
+ className={cn(
376
+ 'group relative flex w-full cursor-default items-center gap-2.5',
377
+ 'rounded-[var(--radius-md)] py-2 pl-3 pr-8 text-sm font-medium',
378
+ 'outline-none text-left',
379
+ 'transition-colors',
380
+ 'hover:bg-[rgb(var(--surface-overlay))]',
381
+ 'focus-visible:bg-[rgb(var(--surface-overlay))]',
382
+ 'disabled:pointer-events-none disabled:opacity-50',
383
+ className,
384
+ )}
385
+ onClick={(event) => {
386
+ onClick?.(event)
387
+ if (!event.defaultPrevented) onCheckedChange?.(!checked)
388
+ }}
389
+ {...rest}
390
+ >
391
+ {children}
392
+ {checked ? (
393
+ <span
394
+ className="pointer-events-none absolute right-2 flex items-center justify-center"
395
+ data-slot="dropdown-menu-checkbox-item-indicator"
396
+ >
397
+ <Check className="h-3.5 w-3.5" />
398
+ </span>
399
+ ) : null}
400
+ </button>
401
+ )
402
+ }
403
+
404
+ export function DropdownMenuSeparator({
405
+ className,
406
+ ...rest
407
+ }: HTMLAttributes<HTMLDivElement>) {
408
+ return (
409
+ <div
410
+ role="separator"
411
+ data-slot="dropdown-menu-separator"
412
+ className={cn('-mx-1.5 my-1.5 h-px', className)}
413
+ style={{ background: 'rgb(var(--border))' }}
414
+ {...rest}
415
+ />
416
+ )
417
+ }
418
+
419
+ export interface DropdownMenuLabelProps
420
+ extends HTMLAttributes<HTMLDivElement> {
421
+ inset?: boolean
422
+ }
423
+
424
+ export function DropdownMenuLabel({
425
+ inset,
426
+ className,
427
+ ...rest
428
+ }: DropdownMenuLabelProps) {
429
+ return (
430
+ <div
431
+ data-slot="dropdown-menu-label"
432
+ className={cn(
433
+ 'px-3 py-2 text-[11px] font-semibold uppercase tracking-[0.4px]',
434
+ inset && 'pl-9',
435
+ className,
436
+ )}
437
+ style={{ color: 'rgb(var(--text-muted))' }}
438
+ {...rest}
439
+ />
440
+ )
441
+ }
442
+
443
+ export function DropdownMenuGroup({
444
+ className,
445
+ ...rest
446
+ }: HTMLAttributes<HTMLDivElement>) {
447
+ return (
448
+ <div role="group" data-slot="dropdown-menu-group" className={className} {...rest} />
449
+ )
450
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * EmptyState — full-area placeholder shown when a lens has no data.
3
+ *
4
+ * Distinct from `EmptyPlaceholder` (in `atoms.tsx`), which is the
5
+ * in-card dashed-border variant used by brand-profile sections. Use
6
+ * `EmptyState` for whole-lens empty states (Products / Personas /
7
+ * Competitors / Assets / etc.) — the prominent centered icon-tile
8
+ * pattern.
9
+ *
10
+ * Design (locked by ADR-019, derived from the Phase 15 Assets pattern):
11
+ * • 56×56 icon tile (surface-overlay background, --radius-lg corner,
12
+ * icon centered, --text-muted color)
13
+ * • 28px Lucide icon inside the tile (the consumer passes a sized
14
+ * element; this primitive does not size the icon)
15
+ * • 15px / semibold title in --foreground
16
+ * • 13px / --text-tertiary description (optional), capped at max-w-md
17
+ * • py-16 vertical padding for whole-lens placement
18
+ * • Action slot below the description (flex-wrap, gap-2)
19
+ *
20
+ * All values come from public tokens. Token boundary clean.
21
+ */
22
+
23
+ import type { ReactNode } from 'react'
24
+
25
+ export interface EmptyStateProps {
26
+ /** Lucide icon (or any ReactNode) rendered inside the 56×56 tile.
27
+ * Pass at h-7 w-7 (28px) for visual parity with the canonical sites. */
28
+ icon: ReactNode
29
+ /** Headline text. */
30
+ title: string
31
+ /** Optional supporting copy. Kept under max-w-md by default. */
32
+ description?: string
33
+ /** Optional action buttons row. Wraps with gap-2. */
34
+ actions?: ReactNode
35
+ /** Optional className override on the outer container — use sparingly
36
+ * (e.g. when the parent already provides vertical padding). */
37
+ className?: string
38
+ }
39
+
40
+ export function EmptyState({
41
+ icon,
42
+ title,
43
+ description,
44
+ actions,
45
+ className,
46
+ }: EmptyStateProps) {
47
+ return (
48
+ <div
49
+ className={
50
+ className ??
51
+ 'mx-auto flex max-w-md flex-col items-center text-center gap-3 py-16'
52
+ }
53
+ >
54
+ <div
55
+ className="inline-flex h-14 w-14 items-center justify-center"
56
+ style={{
57
+ background: 'rgb(var(--surface-overlay))',
58
+ color: 'rgb(var(--text-muted))',
59
+ borderRadius: 'var(--radius-lg)',
60
+ }}
61
+ >
62
+ {icon}
63
+ </div>
64
+ <div
65
+ className="text-[15px] font-semibold"
66
+ style={{ color: 'rgb(var(--foreground))' }}
67
+ >
68
+ {title}
69
+ </div>
70
+ {description && (
71
+ <p
72
+ className="text-[13px]"
73
+ style={{ color: 'rgb(var(--text-tertiary))' }}
74
+ >
75
+ {description}
76
+ </p>
77
+ )}
78
+ {actions && (
79
+ <div className="mt-2 flex flex-wrap items-center justify-center gap-2">
80
+ {actions}
81
+ </div>
82
+ )}
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * FileDropZone — drag-and-drop overlay wrapper for file intake.
3
+ *
4
+ * Promoted from brand-ai-extension (ADR-036 D20).
5
+ */
6
+
7
+ import { useState, useCallback, useRef, type ReactNode } from 'react'
8
+ import { Upload } from 'lucide-react'
9
+ import { cn } from '../lib/utils'
10
+
11
+ interface FileDropZoneProps {
12
+ children: ReactNode
13
+ onFilesDropped: (files: File[]) => void
14
+ disabled?: boolean
15
+ className?: string
16
+ }
17
+
18
+ export function FileDropZone({
19
+ children,
20
+ onFilesDropped,
21
+ disabled = false,
22
+ className,
23
+ }: FileDropZoneProps) {
24
+ const [isDragOver, setIsDragOver] = useState(false)
25
+ const dragCounterRef = useRef(0)
26
+
27
+ // Recursively collect all files from a FileSystem entry (handles folders)
28
+ const getFilesFromEntry = useCallback(async (entry: FileSystemEntry): Promise<File[]> => {
29
+ if (entry.isFile) {
30
+ return new Promise((resolve) => {
31
+ ;(entry as FileSystemFileEntry).file(
32
+ (file) => resolve([file]),
33
+ () => resolve([]),
34
+ )
35
+ })
36
+ }
37
+
38
+ if (entry.isDirectory) {
39
+ const dirReader = (entry as FileSystemDirectoryEntry).createReader()
40
+ const entries = await new Promise<FileSystemEntry[]>((resolve) => {
41
+ dirReader.readEntries(
42
+ (e) => resolve(e),
43
+ () => resolve([]),
44
+ )
45
+ })
46
+
47
+ const allFiles: File[] = []
48
+ for (const childEntry of entries) {
49
+ const files = await getFilesFromEntry(childEntry)
50
+ allFiles.push(...files)
51
+ }
52
+ return allFiles
53
+ }
54
+
55
+ return []
56
+ }, [])
57
+
58
+ const processDroppedItems = useCallback(async (
59
+ items: DataTransferItemList,
60
+ ): Promise<File[]> => {
61
+ const allFiles: File[] = []
62
+
63
+ for (let i = 0; i < items.length; i++) {
64
+ const item = items[i]
65
+ if (!item || item.kind !== 'file') continue
66
+
67
+ const entry = item.webkitGetAsEntry?.()
68
+ if (entry) {
69
+ const files = await getFilesFromEntry(entry)
70
+ allFiles.push(...files)
71
+ } else {
72
+ const file = item.getAsFile()
73
+ if (file) allFiles.push(file)
74
+ }
75
+ }
76
+
77
+ return allFiles
78
+ }, [getFilesFromEntry])
79
+
80
+ const handleDragEnter = useCallback(
81
+ (e: React.DragEvent) => {
82
+ e.preventDefault()
83
+ e.stopPropagation()
84
+ if (disabled) return
85
+
86
+ dragCounterRef.current++
87
+ if (e.dataTransfer.types.includes('Files')) {
88
+ setIsDragOver(true)
89
+ }
90
+ },
91
+ [disabled],
92
+ )
93
+
94
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
95
+ e.preventDefault()
96
+ e.stopPropagation()
97
+
98
+ dragCounterRef.current--
99
+ if (dragCounterRef.current === 0) {
100
+ setIsDragOver(false)
101
+ }
102
+ }, [])
103
+
104
+ const handleDragOver = useCallback((e: React.DragEvent) => {
105
+ e.preventDefault()
106
+ e.stopPropagation()
107
+ }, [])
108
+
109
+ const handleDrop = useCallback(
110
+ async (e: React.DragEvent) => {
111
+ e.preventDefault()
112
+ e.stopPropagation()
113
+ dragCounterRef.current = 0
114
+ setIsDragOver(false)
115
+
116
+ if (disabled) return
117
+
118
+ const files = await processDroppedItems(e.dataTransfer.items)
119
+ if (files.length > 0) {
120
+ onFilesDropped(files)
121
+ }
122
+ },
123
+ [disabled, onFilesDropped, processDroppedItems],
124
+ )
125
+
126
+ return (
127
+ <div
128
+ className={cn('relative', className)}
129
+ onDragEnter={handleDragEnter}
130
+ onDragLeave={handleDragLeave}
131
+ onDragOver={handleDragOver}
132
+ onDrop={handleDrop}
133
+ >
134
+ {children}
135
+
136
+ {isDragOver && !disabled && (
137
+ <div
138
+ className="absolute inset-0 z-50 flex items-center justify-center border-2 border-dashed backdrop-blur-sm"
139
+ style={{
140
+ borderRadius: 'var(--radius-xl)',
141
+ borderColor: 'rgb(var(--accent))',
142
+ background: 'rgb(var(--accent) / 0.08)',
143
+ }}
144
+ >
145
+ <div
146
+ className="flex flex-col items-center gap-2"
147
+ style={{ color: 'rgb(var(--accent))' }}
148
+ >
149
+ <Upload className="h-8 w-8" />
150
+ <span className="text-sm font-medium">Drop files here</span>
151
+ </div>
152
+ </div>
153
+ )}
154
+ </div>
155
+ )
156
+ }