@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.
- package/README.md +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- 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.
|