@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,289 @@
1
+ /**
2
+ * FilterDropdown — pill-shaped "Key: Value ▾" filter trigger that opens
3
+ * a menu of options. Each option can show a right-aligned count.
4
+ *
5
+ * <FilterDropdown
6
+ * label="Platform"
7
+ * value={activePlatform}
8
+ * onChange={setActivePlatform}
9
+ * options={[
10
+ * { value: 'all', label: 'All platforms' },
11
+ * { value: 'facebook', label: 'Facebook', count: 12 },
12
+ * ]}
13
+ * />
14
+ *
15
+ * Visual matches the `prototype/assets.html` filter row: muted-key
16
+ * trigger that flips to accent-muted bg when a non-default value is
17
+ * selected; menu items show count on the right.
18
+ *
19
+ * Promoted from `brand-ai-extension/webapp/src/components/ui/filter-dropdown.tsx`
20
+ * as part of Phase A of the v1.1 lens port batch — used by Swipes
21
+ * (multi-filter strip), candidate for Collections + Prompts.
22
+ */
23
+ import {
24
+ useCallback,
25
+ useEffect,
26
+ useLayoutEffect,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ type ReactNode,
31
+ } from 'react'
32
+ import { ChevronDown, Check } from 'lucide-react'
33
+ import { cn } from './lib/utils'
34
+
35
+ export interface FilterOption<T extends string = string> {
36
+ value: T
37
+ label: string
38
+ /** Right-aligned count chip in the menu. */
39
+ count?: number
40
+ /** Optional left-side adornment (icon, color dot). */
41
+ leading?: ReactNode
42
+ }
43
+
44
+ export interface FilterDropdownProps<T extends string = string> {
45
+ label: string
46
+ value: T
47
+ onChange: (value: T) => void
48
+ options: FilterOption<T>[]
49
+ /** The value treated as "no filter applied" — defaults to "all". When
50
+ * `value` differs from this, the trigger flips to its active style. */
51
+ neutralValue?: T
52
+ /** Defaults to the option label. */
53
+ triggerValueLabel?: string
54
+ className?: string
55
+ align?: 'left' | 'right'
56
+ }
57
+
58
+ export function FilterDropdown<T extends string = string>({
59
+ label,
60
+ value,
61
+ onChange,
62
+ options,
63
+ neutralValue = 'all' as T,
64
+ triggerValueLabel,
65
+ className,
66
+ align = 'left',
67
+ }: FilterDropdownProps<T>) {
68
+ const [open, setOpen] = useState(false)
69
+ const [highlight, setHighlight] = useState(-1)
70
+ const wrapperRef = useRef<HTMLDivElement>(null)
71
+ const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
72
+
73
+ const active = value !== neutralValue
74
+ const selectedIndex = useMemo(
75
+ () => options.findIndex((o) => o.value === value),
76
+ [options, value],
77
+ )
78
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined
79
+ const valueLabel =
80
+ triggerValueLabel ?? (selected ? shortLabel(selected.label) : 'All')
81
+
82
+ const triggerLeading = active ? selected?.leading : undefined
83
+
84
+ const close = useCallback(() => {
85
+ setOpen(false)
86
+ setHighlight(-1)
87
+ }, [])
88
+
89
+ const openMenu = useCallback(() => {
90
+ setOpen(true)
91
+ setHighlight(selectedIndex >= 0 ? selectedIndex : 0)
92
+ }, [selectedIndex])
93
+
94
+ useEffect(() => {
95
+ if (!open) return
96
+ function onDocClick(e: MouseEvent) {
97
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
98
+ close()
99
+ }
100
+ }
101
+ function onKey(e: KeyboardEvent) {
102
+ if (e.key === 'Escape') close()
103
+ }
104
+ document.addEventListener('mousedown', onDocClick)
105
+ document.addEventListener('keydown', onKey)
106
+ return () => {
107
+ document.removeEventListener('mousedown', onDocClick)
108
+ document.removeEventListener('keydown', onKey)
109
+ }
110
+ }, [open, close])
111
+
112
+ useLayoutEffect(() => {
113
+ if (!open || highlight < 0) return
114
+ itemRefs.current[highlight]?.scrollIntoView({ block: 'nearest' })
115
+ }, [open, highlight])
116
+
117
+ const onTriggerKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
118
+ const lastIndex = options.length - 1
119
+ if (e.key === 'ArrowDown') {
120
+ e.preventDefault()
121
+ if (!open) {
122
+ openMenu()
123
+ } else {
124
+ setHighlight((h) => (h >= lastIndex ? 0 : h + 1))
125
+ }
126
+ } else if (e.key === 'ArrowUp') {
127
+ e.preventDefault()
128
+ if (!open) {
129
+ openMenu()
130
+ } else {
131
+ setHighlight((h) => (h <= 0 ? lastIndex : h - 1))
132
+ }
133
+ } else if (e.key === 'Home') {
134
+ if (open) {
135
+ e.preventDefault()
136
+ setHighlight(0)
137
+ }
138
+ } else if (e.key === 'End') {
139
+ if (open) {
140
+ e.preventDefault()
141
+ setHighlight(lastIndex)
142
+ }
143
+ } else if (e.key === 'Enter' || e.key === ' ') {
144
+ if (open && highlight >= 0) {
145
+ e.preventDefault()
146
+ const opt = options[highlight]
147
+ if (opt) {
148
+ onChange(opt.value)
149
+ close()
150
+ }
151
+ }
152
+ }
153
+ }
154
+
155
+ return (
156
+ <div
157
+ ref={wrapperRef}
158
+ className={cn('relative inline-block', className)}
159
+ >
160
+ <button
161
+ type="button"
162
+ onClick={() => (open ? close() : openMenu())}
163
+ onKeyDown={onTriggerKeyDown}
164
+ aria-haspopup="listbox"
165
+ aria-expanded={open}
166
+ className="inline-flex items-center gap-1.5 h-8 px-3 text-[12px] font-medium cursor-pointer transition-colors"
167
+ style={{
168
+ background: active
169
+ ? 'rgb(var(--accent-muted))'
170
+ : 'rgb(var(--bg-elevated))',
171
+ border: `1px solid ${active ? 'rgb(var(--accent) / 0.35)' : 'rgb(var(--border))'}`,
172
+ color: active ? 'rgb(var(--accent))' : 'rgb(var(--text-secondary))',
173
+ borderRadius: 999,
174
+ }}
175
+ >
176
+ <span
177
+ className="font-semibold"
178
+ style={{
179
+ color: active ? 'rgb(var(--accent))' : 'rgb(var(--text-muted))',
180
+ }}
181
+ >
182
+ {label}:
183
+ </span>
184
+ {triggerLeading && (
185
+ <span className="shrink-0 inline-flex items-center">
186
+ {triggerLeading}
187
+ </span>
188
+ )}
189
+ <span
190
+ style={{
191
+ color: active ? 'rgb(var(--accent))' : 'rgb(var(--foreground))',
192
+ }}
193
+ >
194
+ {valueLabel}
195
+ </span>
196
+ <ChevronDown
197
+ className={cn('h-3 w-3 transition-transform', open && 'rotate-180')}
198
+ style={{
199
+ color: active ? 'rgb(var(--accent))' : 'rgb(var(--text-muted))',
200
+ }}
201
+ />
202
+ </button>
203
+
204
+ {open && (
205
+ <div
206
+ role="listbox"
207
+ aria-label={label}
208
+ aria-activedescendant={
209
+ highlight >= 0 ? `${label}-opt-${highlight}` : undefined
210
+ }
211
+ className={cn(
212
+ 'absolute z-30 mt-1.5 min-w-[200px] p-1.5',
213
+ align === 'right' ? 'right-0' : 'left-0',
214
+ )}
215
+ style={{
216
+ background: 'rgb(var(--bg-elevated))',
217
+ border: '1px solid rgb(var(--border))',
218
+ borderRadius: 'var(--radius-md)',
219
+ boxShadow: 'var(--shadow-lg)',
220
+ }}
221
+ >
222
+ {options.map((opt, i) => {
223
+ const isActive = opt.value === value
224
+ const isHighlighted = i === highlight
225
+ const bg = isActive
226
+ ? 'rgb(var(--accent-muted))'
227
+ : isHighlighted
228
+ ? 'rgb(var(--surface-overlay))'
229
+ : 'transparent'
230
+ const fg = isActive
231
+ ? 'rgb(var(--accent))'
232
+ : isHighlighted
233
+ ? 'rgb(var(--foreground))'
234
+ : 'rgb(var(--text-secondary))'
235
+ return (
236
+ <button
237
+ key={opt.value}
238
+ ref={(el) => {
239
+ itemRefs.current[i] = el
240
+ }}
241
+ id={`${label}-opt-${i}`}
242
+ role="option"
243
+ aria-selected={isActive}
244
+ type="button"
245
+ onClick={() => {
246
+ onChange(opt.value)
247
+ close()
248
+ }}
249
+ onMouseEnter={() => setHighlight(i)}
250
+ className="flex w-full items-center gap-2 px-2.5 py-[7px] text-[12.5px] text-left cursor-pointer rounded-[8px] border-0"
251
+ style={{ background: bg, color: fg }}
252
+ >
253
+ {opt.leading && (
254
+ <span className="shrink-0 inline-flex items-center">
255
+ {opt.leading}
256
+ </span>
257
+ )}
258
+ <span className="truncate flex-1">{opt.label}</span>
259
+ {typeof opt.count === 'number' && (
260
+ <span
261
+ className="text-[11px] font-medium tabular-nums"
262
+ style={{
263
+ color: isActive
264
+ ? 'rgb(var(--accent) / 0.7)'
265
+ : 'rgb(var(--text-muted))',
266
+ }}
267
+ >
268
+ {opt.count.toLocaleString()}
269
+ </span>
270
+ )}
271
+ {isActive && typeof opt.count !== 'number' && (
272
+ <Check
273
+ className="h-3 w-3 shrink-0"
274
+ style={{ color: 'rgb(var(--accent))' }}
275
+ />
276
+ )}
277
+ </button>
278
+ )
279
+ })}
280
+ </div>
281
+ )}
282
+ </div>
283
+ )
284
+ }
285
+
286
+ function shortLabel(label: string) {
287
+ if (label.toLowerCase().startsWith('all ')) return 'All'
288
+ return label
289
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * FloatingDrawer — edge-anchored, floating-shell drawer.
3
+ *
4
+ * Promoted in Phase 7a (ADR-012 Decision 4) from
5
+ * brand-ai-extension/webapp/src/components/ui/detail-drawer.tsx. The
6
+ * right-anchored variant ports verbatim from that source. The bottom-
7
+ * anchored variant with drag-to-resize is BUILD_FRESH inside this
8
+ * primitive, born of GeoSearch's ResultsDrawer needs.
9
+ *
10
+ * Two committed consumers from day 1 (zero-current-consumers rule):
11
+ * • GeoSearch (`side="bottom"`, `anchor="container"`, resizable) —
12
+ * Phase 7a — non-modal, scoped to the lens main pane
13
+ * • Future card-grid lenses (`side="right"`, modal) — Phase 4+
14
+ *
15
+ * Anatomy:
16
+ * ResizeHandle (optional) — leading edge for drag-to-resize
17
+ * Header (title + titleSlot) — single compact row; consumer
18
+ * drives layout via `title` slot
19
+ * Body (scrollable children)
20
+ * Footer (optional) — action row, separator above
21
+ *
22
+ * The drawer owns its internal token references (`--bg-surface`,
23
+ * `--border`, `--modal-overlay`) — consumers never reach for them.
24
+ */
25
+
26
+ import {
27
+ useCallback,
28
+ useEffect,
29
+ useRef,
30
+ useState,
31
+ type PointerEvent as ReactPointerEvent,
32
+ type ReactNode,
33
+ } from 'react'
34
+ import { X } from 'lucide-react'
35
+ import { cn } from './lib/utils'
36
+
37
+ type Side = 'right' | 'bottom'
38
+ type Anchor = 'viewport' | 'container'
39
+
40
+ export interface FloatingDrawerProps {
41
+ isOpen: boolean
42
+ onClose: () => void
43
+ side: Side
44
+ /** Width (`side="right"`) or height (`side="bottom"`) in px. Defaults: 520 / 480. */
45
+ size?: number
46
+ /** When true, render a drag handle along the leading edge of the panel. */
47
+ resizable?: boolean
48
+ /** Called as the user drags the resize handle. */
49
+ onResize?: (next: number) => void
50
+ /** Header title — short, e.g. the record's name or the drawer's purpose. */
51
+ title?: ReactNode
52
+ /** Optional element rendered to the right of the title, before close. */
53
+ titleSlot?: ReactNode
54
+ /** Optional footer — action buttons. */
55
+ footer?: ReactNode
56
+ children: ReactNode
57
+ className?: string
58
+ /** Optional className on the inner panel for fine-grained body styling. */
59
+ panelClassName?: string
60
+ /**
61
+ * Modal mode renders a backdrop, locks body scroll, and closes on Esc
62
+ * (default — matches the card-grid drawer use case). Non-modal mode
63
+ * (used by GeoSearch's always-on results panel per Phase 7a) skips
64
+ * the backdrop + scroll lock + Esc handler so the user can interact
65
+ * with content behind the panel.
66
+ */
67
+ modal?: boolean
68
+ /**
69
+ * Collapsed mode hides the body and footer entirely so the panel
70
+ * shrinks to just the header — no residual flex-1 body div, no
71
+ * seam between header and panel edge. Designed for non-modal,
72
+ * always-on consumers (GeoSearch) where the user toggles the panel
73
+ * between "show table" and "hide table" without dismissing it.
74
+ * The header keeps its border-bottom hidden in this state so it
75
+ * reads as a clean single-bar strip.
76
+ *
77
+ * When collapsed, callers should pass `size` matching the header's
78
+ * natural height (Phase 7a uses 44px); panel border + header chrome
79
+ * fit cleanly inside that height with `box-sizing: border-box`.
80
+ */
81
+ collapsed?: boolean
82
+ /**
83
+ * `'viewport'` (default) anchors the drawer with `position: fixed` to
84
+ * the browser viewport — the card-grid drawer pattern. `'container'`
85
+ * anchors with `position: absolute` to the nearest positioned ancestor
86
+ * (the parent MUST establish a containing block, e.g.
87
+ * `position: relative`). Container mode scopes the drawer to a lens
88
+ * pane so it doesn't escape the surrounding chrome — used by
89
+ * GeoSearch in Phase 7a.
90
+ */
91
+ anchor?: Anchor
92
+ /** Whether to render a default close (X) button after `titleSlot`. Default true. */
93
+ showClose?: boolean
94
+ /** Override the header padding. Default: `px-4 py-2.5` — tighter than
95
+ * the right-drawer's body padding so the action bar feels integrated. */
96
+ headerClassName?: string
97
+ }
98
+
99
+ const DEFAULT_SIZE: Record<Side, number> = { right: 520, bottom: 480 }
100
+ // Bottom drawer minimum — at least header + a row of content visible.
101
+ const MIN_SIZE: Record<Side, number> = { right: 360, bottom: 180 }
102
+ // 16px of gap budget keeps the floating shell off the screen edges.
103
+ const EDGE_GAP = 8
104
+
105
+ export function FloatingDrawer({
106
+ isOpen,
107
+ onClose,
108
+ side,
109
+ size,
110
+ resizable = false,
111
+ onResize,
112
+ title,
113
+ titleSlot,
114
+ footer,
115
+ children,
116
+ className,
117
+ panelClassName,
118
+ modal = true,
119
+ anchor = 'viewport',
120
+ showClose = true,
121
+ headerClassName,
122
+ collapsed = false,
123
+ }: FloatingDrawerProps) {
124
+ const resolvedSize = size ?? DEFAULT_SIZE[side]
125
+
126
+ // Close on Esc — modal mode only.
127
+ useEffect(() => {
128
+ if (!isOpen || !modal) return
129
+ const onKey = (e: KeyboardEvent) => {
130
+ if (e.key === 'Escape') onClose()
131
+ }
132
+ document.addEventListener('keydown', onKey)
133
+ return () => document.removeEventListener('keydown', onKey)
134
+ }, [isOpen, onClose, modal])
135
+
136
+ // Body scroll lock — modal mode only.
137
+ useEffect(() => {
138
+ if (!isOpen || !modal) return
139
+ const prev = document.body.style.overflow
140
+ document.body.style.overflow = 'hidden'
141
+ return () => {
142
+ document.body.style.overflow = prev
143
+ }
144
+ }, [isOpen, modal])
145
+
146
+ // Pointer-drag resize. Tracks the live size locally so the handle feels
147
+ // responsive even when the parent throttles `onResize` updates.
148
+ const [dragSize, setDragSize] = useState<number | null>(null)
149
+ const draggingRef = useRef(false)
150
+
151
+ const onPointerDown = useCallback(
152
+ (event: ReactPointerEvent<HTMLDivElement>) => {
153
+ if (!resizable) return
154
+ event.preventDefault()
155
+ draggingRef.current = true
156
+ const startCoord = side === 'bottom' ? event.clientY : event.clientX
157
+ const startSize = resolvedSize
158
+
159
+ const onMove = (e: PointerEvent) => {
160
+ if (!draggingRef.current) return
161
+ const coord = side === 'bottom' ? e.clientY : e.clientX
162
+ const delta = startCoord - coord
163
+ const next = Math.max(MIN_SIZE[side], startSize + delta)
164
+ setDragSize(next)
165
+ onResize?.(next)
166
+ }
167
+
168
+ const onUp = () => {
169
+ draggingRef.current = false
170
+ setDragSize(null)
171
+ document.removeEventListener('pointermove', onMove)
172
+ document.removeEventListener('pointerup', onUp)
173
+ }
174
+
175
+ document.addEventListener('pointermove', onMove)
176
+ document.addEventListener('pointerup', onUp)
177
+ },
178
+ [resizable, side, resolvedSize, onResize],
179
+ )
180
+
181
+ const liveSize = dragSize ?? resolvedSize
182
+ const isContainer = anchor === 'container'
183
+
184
+ // Viewport vs container only differs in (a) position and (b) maxSize
185
+ // bounds. Edge gap + transform geometry is identical.
186
+ const panelStyle =
187
+ side === 'right'
188
+ ? {
189
+ position: isContainer ? ('absolute' as const) : ('fixed' as const),
190
+ top: EDGE_GAP,
191
+ right: EDGE_GAP,
192
+ bottom: EDGE_GAP,
193
+ width: liveSize,
194
+ maxWidth: isContainer
195
+ ? `calc(100% - ${EDGE_GAP * 2}px)`
196
+ : 'calc(100vw - 16px)',
197
+ boxShadow: '-8px 0 40px rgba(0,0,0,0.3)',
198
+ transform: isOpen
199
+ ? 'translateX(0)'
200
+ : 'translateX(calc(100% + 16px))',
201
+ }
202
+ : {
203
+ position: isContainer ? ('absolute' as const) : ('fixed' as const),
204
+ left: EDGE_GAP,
205
+ right: EDGE_GAP,
206
+ bottom: EDGE_GAP,
207
+ height: liveSize,
208
+ maxHeight: isContainer
209
+ ? `calc(100% - ${EDGE_GAP * 2}px)`
210
+ : 'calc(100dvh - 16px)',
211
+ boxShadow: '0 -8px 40px rgba(0,0,0,0.3)',
212
+ transform: isOpen
213
+ ? 'translateY(0)'
214
+ : 'translateY(calc(100% + 16px))',
215
+ }
216
+
217
+ const backdropClass = isContainer
218
+ ? 'absolute inset-0 z-[40] transition-opacity duration-200'
219
+ : 'fixed inset-0 z-[99] transition-opacity duration-200'
220
+
221
+ const panelZ = isContainer ? 'z-[50]' : 'z-[100]'
222
+
223
+ return (
224
+ <>
225
+ {/* Backdrop — modal mode only. */}
226
+ {modal && (
227
+ <div
228
+ onClick={onClose}
229
+ aria-hidden="true"
230
+ className={cn(
231
+ backdropClass,
232
+ isOpen
233
+ ? 'opacity-100 pointer-events-auto'
234
+ : 'opacity-0 pointer-events-none',
235
+ )}
236
+ style={{ background: 'rgb(var(--modal-overlay))' }}
237
+ />
238
+ )}
239
+
240
+ {/* Drawer panel */}
241
+ <aside
242
+ role="dialog"
243
+ aria-modal={modal ? 'true' : undefined}
244
+ aria-label={typeof title === 'string' ? title : undefined}
245
+ className={cn(
246
+ panelZ,
247
+ 'flex flex-col overflow-hidden',
248
+ 'transition-transform duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]',
249
+ className,
250
+ )}
251
+ style={{
252
+ ...panelStyle,
253
+ background: 'rgb(var(--bg-surface))',
254
+ border: '1px solid rgb(var(--border))',
255
+ borderRadius: 'var(--radius-2xl)',
256
+ }}
257
+ >
258
+ {resizable && (
259
+ <ResizeHandle side={side} onPointerDown={onPointerDown} />
260
+ )}
261
+
262
+ {/* Header — single compact row. Consumer provides layout inside
263
+ `title` + `titleSlot`. Default close button can be hidden via
264
+ `showClose={false}` for non-modal always-on consumers. The
265
+ border-bottom is dropped when `collapsed` so the panel reads
266
+ as a clean single-bar strip without a separator line above
267
+ an empty area. */}
268
+ {(title || titleSlot || showClose) && (
269
+ <div
270
+ className={cn(
271
+ 'flex items-center justify-between gap-3 shrink-0',
272
+ headerClassName ?? 'px-4 py-2.5',
273
+ )}
274
+ style={
275
+ collapsed
276
+ ? undefined
277
+ : { borderBottom: '1px solid rgb(var(--border))' }
278
+ }
279
+ >
280
+ <div className="flex items-center gap-2 flex-1 min-w-0">
281
+ {title && (
282
+ <div
283
+ className="text-[13px] font-semibold flex items-center gap-2 min-w-0"
284
+ style={{ color: 'rgb(var(--foreground))' }}
285
+ >
286
+ {title}
287
+ </div>
288
+ )}
289
+ {titleSlot && (
290
+ <div className="flex items-center gap-2 min-w-0 flex-1">
291
+ {titleSlot}
292
+ </div>
293
+ )}
294
+ </div>
295
+ {showClose && <CloseButton onClose={onClose} />}
296
+ </div>
297
+ )}
298
+
299
+ {/* Body — omitted entirely when collapsed so no residual flex-1
300
+ div eats space below the header. */}
301
+ {!collapsed && (
302
+ <div
303
+ className={cn(
304
+ 'flex-1 overflow-y-auto',
305
+ panelClassName ?? 'px-4 py-3',
306
+ )}
307
+ >
308
+ {children}
309
+ </div>
310
+ )}
311
+
312
+ {/* Footer — also omitted when collapsed. */}
313
+ {!collapsed && footer && (
314
+ <div
315
+ className="flex items-center gap-2 px-4 py-2.5 shrink-0"
316
+ style={{ borderTop: '1px solid rgb(var(--border))' }}
317
+ >
318
+ {footer}
319
+ </div>
320
+ )}
321
+ </aside>
322
+ </>
323
+ )
324
+ }
325
+
326
+ function CloseButton({ onClose }: { onClose: () => void }) {
327
+ return (
328
+ <button
329
+ onClick={onClose}
330
+ aria-label="Close"
331
+ className="w-7 h-7 flex items-center justify-center rounded-[6px] shrink-0 transition-colors"
332
+ style={{
333
+ background: 'transparent',
334
+ color: 'rgb(var(--text-tertiary))',
335
+ }}
336
+ onMouseEnter={(e) => {
337
+ e.currentTarget.style.background = 'rgb(var(--surface-overlay))'
338
+ e.currentTarget.style.color = 'rgb(var(--foreground))'
339
+ }}
340
+ onMouseLeave={(e) => {
341
+ e.currentTarget.style.background = 'transparent'
342
+ e.currentTarget.style.color = 'rgb(var(--text-tertiary))'
343
+ }}
344
+ >
345
+ <X className="h-3.5 w-3.5" />
346
+ </button>
347
+ )
348
+ }
349
+
350
+ function ResizeHandle({
351
+ side,
352
+ onPointerDown,
353
+ }: {
354
+ side: Side
355
+ onPointerDown: (event: ReactPointerEvent<HTMLDivElement>) => void
356
+ }) {
357
+ // `side="right"` puts the grab strip on the LEFT edge of the panel
358
+ // (user drags leftward to widen); `side="bottom"` puts it on the TOP
359
+ // edge (user drags upward to grow).
360
+ const positional =
361
+ side === 'right'
362
+ ? 'top-0 bottom-0 left-0 w-1.5 cursor-ew-resize'
363
+ : 'left-0 right-0 top-0 h-1.5 cursor-ns-resize'
364
+
365
+ return (
366
+ <div
367
+ role="separator"
368
+ aria-orientation={side === 'right' ? 'vertical' : 'horizontal'}
369
+ onPointerDown={onPointerDown}
370
+ className={cn(
371
+ 'absolute z-10 flex items-center justify-center group',
372
+ positional,
373
+ )}
374
+ style={{ touchAction: 'none' }}
375
+ >
376
+ <span
377
+ className={cn(
378
+ 'rounded-full transition-opacity',
379
+ side === 'right' ? 'h-8 w-0.5' : 'h-0.5 w-8',
380
+ 'opacity-30 group-hover:opacity-100',
381
+ )}
382
+ style={{ background: 'rgb(var(--border-strong))' }}
383
+ />
384
+ </div>
385
+ )
386
+ }
387
+
388
+ /**
389
+ * DetailSection — small label/value layout for drawer body content.
390
+ * Ports verbatim from brand-ai-extension's detail-drawer.tsx.
391
+ */
392
+ export function DetailSection({
393
+ label,
394
+ children,
395
+ className,
396
+ }: {
397
+ label: string
398
+ children: ReactNode
399
+ className?: string
400
+ }) {
401
+ return (
402
+ <div className={cn('mb-6', className)}>
403
+ <div
404
+ className="text-[11px] font-semibold uppercase tracking-[0.4px] mb-2"
405
+ style={{ color: 'rgb(var(--text-muted))' }}
406
+ >
407
+ {label}
408
+ </div>
409
+ <div
410
+ className="text-[14px] leading-[1.6]"
411
+ style={{ color: 'rgb(var(--foreground))' }}
412
+ >
413
+ {children}
414
+ </div>
415
+ </div>
416
+ )
417
+ }
418
+
419
+ // Default export intentionally omitted; named export is the canonical
420
+ // import path. Re-exported via packages/ui/src/index.ts.