@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,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
|
+
}
|