@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,274 @@
1
+ /**
2
+ * DeviceFrame — a generic, content-agnostic device bezel.
3
+ *
4
+ * Renders the chrome of a real device around arbitrary `children`:
5
+ * • `device='desktop'` → a macOS Safari browser window (rounded card,
6
+ * gradient title bar with the three traffic-light dots, centered
7
+ * address bar with a lock icon + URL).
8
+ * • `device='mobile'` → an iPhone (rounded bezel, top notch, status bar
9
+ * with 9:41 + signal/wifi/battery, an address pill, and the bottom
10
+ * home-indicator bar).
11
+ *
12
+ * The frame is pure presentation. `children` are the page being previewed;
13
+ * they render inside the screen area, which scrolls and clips with the
14
+ * correct rounded corners. Nothing about Google / SERPs / any specific site
15
+ * is baked in — pass whatever content you like.
16
+ *
17
+ * Promoted (genericized) from the sem-spec source app's `SerpCard`
18
+ * `MacOSFrame` + `IPhoneFrame`. First reuse target: AdStage. The sem-spec
19
+ * lens does not yet consume this.
20
+ *
21
+ * Icon discipline (CLAUDE.md §6): inline `<svg>` is permitted here because
22
+ * this is a `@lovett/ui` primitive that OWNS its device-replica glyph
23
+ * definitions (iOS status-bar signal/wifi/battery). The lock icon uses
24
+ * Lucide where a clean equivalent exists.
25
+ *
26
+ * Colors: device-replica chrome — literal colors mirroring real macOS / iOS
27
+ * UI; owned by this primitive (like the platform-chrome allowlist), NOT the
28
+ * theme palette. They live in the `DEVICE` constant below and are referenced
29
+ * by name, never scattered as raw hex through the JSX. `packages/ui` is not
30
+ * scanned by the app-level hex-grep, so this is gate-clean.
31
+ */
32
+
33
+ import type { ReactNode } from 'react'
34
+ import { Lock } from 'lucide-react'
35
+ import { cn } from './lib/utils'
36
+
37
+ /**
38
+ * Device-replica chrome palette — literal colors mirroring real macOS / iOS
39
+ * UI. Owned by this primitive (like the platform-chrome allowlist), not the
40
+ * theme palette. Reference by name; do not inline raw hex in the JSX.
41
+ */
42
+ const DEVICE = {
43
+ /** iPhone bezel + status-bar ink + home indicator. */
44
+ bezel: '#1D1D1F',
45
+ /** Screen background behind children. */
46
+ screen: '#ffffff',
47
+ /** macOS window outer border. */
48
+ windowBorder: '#D1D5DB',
49
+ /** macOS title-bar gradient stops + divider. */
50
+ titleBarTop: '#E8E8E8',
51
+ titleBarBottom: '#D4D4D4',
52
+ titleBarBorder: '#BFBFBF',
53
+ /** macOS address-bar fill + border. */
54
+ addressFill: '#ffffff',
55
+ addressBorder: '#C4C4C4',
56
+ /** macOS / iOS address-bar text + lock glyph. */
57
+ addressText: '#5F6368',
58
+ /** iOS address pill fill. */
59
+ pillFill: '#F1F3F4',
60
+ /** Traffic-light dots (fill + matching darker border ring). */
61
+ trafficRed: '#FF5F57',
62
+ trafficRedBorder: '#E0443E',
63
+ trafficAmber: '#FEBC2E',
64
+ trafficAmberBorder: '#DEA123',
65
+ trafficGreen: '#28C840',
66
+ trafficGreenBorder: '#1AAB29',
67
+ } as const
68
+
69
+ const PLACEHOLDER_URL = 'example.com'
70
+
71
+ export interface DeviceFrameProps {
72
+ /** Which device bezel to render. */
73
+ device: 'desktop' | 'mobile'
74
+ /**
75
+ * URL shown in the address bar / pill. On desktop, the address bar is
76
+ * hidden when omitted; on mobile the pill falls back to a neutral
77
+ * placeholder.
78
+ */
79
+ url?: string
80
+ /** The page being previewed, rendered inside the (scrollable) screen area. */
81
+ children: ReactNode
82
+ /** Optional className on the outer frame element. */
83
+ className?: string
84
+ }
85
+
86
+ /* ------------------------------------------------------------------ */
87
+ /* iOS status-bar glyphs (device-replica SVG definitions) */
88
+ /* ------------------------------------------------------------------ */
89
+
90
+ function CellularGlyph() {
91
+ return (
92
+ <svg className="w-4 h-3" viewBox="0 0 17 12" fill={DEVICE.bezel} aria-hidden="true">
93
+ <path d="M1.5 5.5A.5.5 0 0 1 2 5h1a.5.5 0 0 1 .5.5v5a.5.5 0 0 1-.5.5H2a.5.5 0 0 1-.5-.5v-5Zm3-2A.5.5 0 0 1 5 3h1a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5H5a.5.5 0 0 1-.5-.5v-7Zm3-2A.5.5 0 0 1 8 1h1a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5H8a.5.5 0 0 1-.5-.5v-9Z" />
94
+ </svg>
95
+ )
96
+ }
97
+
98
+ function WifiGlyph() {
99
+ return (
100
+ <svg className="w-4 h-3" viewBox="0 0 16 12" fill={DEVICE.bezel} aria-hidden="true">
101
+ <path d="M8 3.3a6.2 6.2 0 0 1 4.2 1.6l1.1-1.2A8 8 0 0 0 8 1.3a8 8 0 0 0-5.3 2.4l1.1 1.2A6.2 6.2 0 0 1 8 3.3Zm0 3.2c1 0 2 .4 2.7 1l1.1-1.2A5.2 5.2 0 0 0 8 4.7a5.2 5.2 0 0 0-3.8 1.6l1.1 1.2c.7-.6 1.7-1 2.7-1Z" />
102
+ </svg>
103
+ )
104
+ }
105
+
106
+ function BatteryGlyph() {
107
+ return (
108
+ <div className="flex items-center" aria-hidden="true">
109
+ <div
110
+ className="w-[22px] h-[10px] rounded-[3px] border p-[1px]"
111
+ style={{ borderColor: DEVICE.bezel }}
112
+ >
113
+ <div
114
+ className="w-full h-full rounded-[1.5px]"
115
+ style={{ backgroundColor: DEVICE.bezel }}
116
+ />
117
+ </div>
118
+ </div>
119
+ )
120
+ }
121
+
122
+ /** Inner-frame props — `url` / `className` are explicitly optional (the
123
+ * public `DeviceFrame` already validated them; the helpers just forward). */
124
+ interface InnerFrameProps {
125
+ url: string | undefined
126
+ children: ReactNode
127
+ className: string | undefined
128
+ }
129
+
130
+ /* ------------------------------------------------------------------ */
131
+ /* macOS Safari browser window */
132
+ /* ------------------------------------------------------------------ */
133
+
134
+ function DesktopFrame({ url, children, className }: InnerFrameProps) {
135
+ return (
136
+ <div
137
+ className={cn('rounded-xl overflow-hidden border shadow-lg', className)}
138
+ style={{ borderColor: DEVICE.windowBorder, backgroundColor: DEVICE.screen }}
139
+ >
140
+ {/* Title bar */}
141
+ <div
142
+ className="px-4 py-2.5 flex items-center gap-3 border-b"
143
+ style={{
144
+ backgroundImage: `linear-gradient(to bottom, ${DEVICE.titleBarTop}, ${DEVICE.titleBarBottom})`,
145
+ borderColor: DEVICE.titleBarBorder,
146
+ }}
147
+ aria-hidden="true"
148
+ >
149
+ {/* Traffic lights */}
150
+ <div className="flex items-center gap-[6px]">
151
+ <span
152
+ className="w-3 h-3 rounded-full border"
153
+ style={{ backgroundColor: DEVICE.trafficRed, borderColor: DEVICE.trafficRedBorder }}
154
+ />
155
+ <span
156
+ className="w-3 h-3 rounded-full border"
157
+ style={{ backgroundColor: DEVICE.trafficAmber, borderColor: DEVICE.trafficAmberBorder }}
158
+ />
159
+ <span
160
+ className="w-3 h-3 rounded-full border"
161
+ style={{ backgroundColor: DEVICE.trafficGreen, borderColor: DEVICE.trafficGreenBorder }}
162
+ />
163
+ </div>
164
+
165
+ {/* Address bar (hidden when no url) */}
166
+ {url && (
167
+ <div className="flex-1 mx-8">
168
+ <div
169
+ className="rounded-md border px-3 py-1 flex items-center justify-center shadow-inner"
170
+ style={{ backgroundColor: DEVICE.addressFill, borderColor: DEVICE.addressBorder }}
171
+ >
172
+ <div className="flex items-center gap-1.5">
173
+ <Lock className="w-3 h-3" style={{ color: DEVICE.addressText }} aria-hidden="true" />
174
+ <span className="text-[12px]" style={{ color: DEVICE.addressText }}>
175
+ {url}
176
+ </span>
177
+ </div>
178
+ </div>
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ {/* Screen — clips to the window's rounded bottom corners */}
184
+ <div className="overflow-auto" style={{ backgroundColor: DEVICE.screen }}>
185
+ {children}
186
+ </div>
187
+ </div>
188
+ )
189
+ }
190
+
191
+ /* ------------------------------------------------------------------ */
192
+ /* iPhone frame */
193
+ /* ------------------------------------------------------------------ */
194
+
195
+ function MobileFrame({ url, children, className }: InnerFrameProps) {
196
+ return (
197
+ <div className={cn('mx-auto', className)} style={{ maxWidth: 375 }}>
198
+ <div
199
+ className="rounded-[40px] border-[3px] p-1 shadow-2xl"
200
+ style={{ borderColor: DEVICE.bezel, backgroundColor: DEVICE.bezel }}
201
+ >
202
+ {/* Notch */}
203
+ <div className="relative" aria-hidden="true">
204
+ <div
205
+ className="absolute top-0 left-1/2 -translate-x-1/2 z-10 w-[120px] h-[30px] rounded-b-[16px]"
206
+ style={{ backgroundColor: DEVICE.bezel }}
207
+ />
208
+ </div>
209
+
210
+ <div className="rounded-[36px] overflow-hidden" style={{ backgroundColor: DEVICE.screen }}>
211
+ {/* Status bar */}
212
+ <div
213
+ className="px-6 pt-3 pb-1 flex items-center justify-between"
214
+ style={{ backgroundColor: DEVICE.screen }}
215
+ aria-hidden="true"
216
+ >
217
+ <span className="text-[12px] font-semibold" style={{ color: DEVICE.bezel }}>
218
+ 9:41
219
+ </span>
220
+ <div className="flex items-center gap-1">
221
+ <CellularGlyph />
222
+ <WifiGlyph />
223
+ <BatteryGlyph />
224
+ </div>
225
+ </div>
226
+
227
+ {/* Address pill */}
228
+ <div className="px-3 pb-1" aria-hidden="true">
229
+ <div
230
+ className="rounded-full px-3 py-1.5 flex items-center gap-2"
231
+ style={{ backgroundColor: DEVICE.pillFill }}
232
+ >
233
+ <Lock
234
+ className="w-3 h-3 flex-shrink-0"
235
+ style={{ color: DEVICE.addressText }}
236
+ aria-hidden="true"
237
+ />
238
+ <span className="text-[12px] truncate" style={{ color: DEVICE.addressText }}>
239
+ {url || PLACEHOLDER_URL}
240
+ </span>
241
+ </div>
242
+ </div>
243
+
244
+ {/* Screen */}
245
+ <div className="overflow-auto">{children}</div>
246
+
247
+ {/* Home indicator */}
248
+ <div className="flex justify-center py-2" aria-hidden="true">
249
+ <div className="w-32 h-1 rounded-full" style={{ backgroundColor: DEVICE.bezel }} />
250
+ </div>
251
+ </div>
252
+ </div>
253
+ </div>
254
+ )
255
+ }
256
+
257
+ /* ------------------------------------------------------------------ */
258
+ /* Public component */
259
+ /* ------------------------------------------------------------------ */
260
+
261
+ export function DeviceFrame({ device, url, children, className }: DeviceFrameProps) {
262
+ if (device === 'mobile') {
263
+ return (
264
+ <MobileFrame url={url} className={className}>
265
+ {children}
266
+ </MobileFrame>
267
+ )
268
+ }
269
+ return (
270
+ <DesktopFrame url={url} className={className}>
271
+ {children}
272
+ </DesktopFrame>
273
+ )
274
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * DropdownButton — Button-styled trigger that opens a rich popover menu.
3
+ *
4
+ * The split-button pattern from the design reference: a primary Button
5
+ * with a trailing chevron-down, which on click reveals a popover of
6
+ * actions. Items render as `icon + title + description` rows.
7
+ *
8
+ * Built on top of <DropdownMenu> + <DropdownMenuTrigger> +
9
+ * <DropdownMenuContent> + <DropdownMenuItem>. Use this primitive when:
10
+ * • A toolbar CTA has 2+ creation paths that share a noun (e.g.
11
+ * "New Keyword Plan" → Set OR Group).
12
+ * • You want a "primary action + secondary picker" feel without
13
+ * resorting to multiple side-by-side buttons.
14
+ *
15
+ * For arbitrary menu content (sort pickers, kebab menus, etc.), use
16
+ * the lower-level <DropdownMenu> compound directly.
17
+ *
18
+ * Implementation note: the trigger renders the .btn class chain
19
+ * directly on <DropdownMenuTrigger> (rather than wrapping a <Button>),
20
+ * so the rendered DOM stays a single <button>. The variant/size class
21
+ * maps mirror <Button> 1:1.
22
+ */
23
+
24
+ import { useId, type ReactNode } from 'react'
25
+ import { Check, ChevronDown } from 'lucide-react'
26
+ import type { ButtonVariant, ButtonSize } from './button'
27
+ import {
28
+ DropdownMenu,
29
+ DropdownMenuContent,
30
+ DropdownMenuItem,
31
+ DropdownMenuTrigger,
32
+ type DropdownMenuContentProps,
33
+ } from './dropdown-menu'
34
+ import { cn } from './lib/utils'
35
+
36
+ const VARIANT_CLASS: Record<ButtonVariant, string> = {
37
+ primary: 'btn-primary',
38
+ secondary: 'btn-secondary',
39
+ outline: 'btn-outline',
40
+ ghost: 'btn-ghost',
41
+ destructive: 'btn-destructive',
42
+ 'destructive-soft': 'btn-destructive-soft',
43
+ icon: 'btn-icon',
44
+ toolbar: 'btn-toolbar',
45
+ }
46
+
47
+ const SIZE_CLASS: Record<ButtonSize, string> = {
48
+ sm: 'size-sm',
49
+ md: '',
50
+ lg: 'size-lg',
51
+ }
52
+
53
+ export interface DropdownButtonItem {
54
+ /** Stable id for keying. If omitted, the index is used (avoid for
55
+ * long lists where order can change). */
56
+ id?: string
57
+ /** Icon shown in the leading tile (Lucide or any ReactNode). */
58
+ icon?: ReactNode
59
+ /** Primary label of the item. */
60
+ label: string
61
+ /** Optional helper text rendered below the label. */
62
+ description?: string
63
+ /** Visual variant — `default` or `destructive`. */
64
+ variant?: 'default' | 'destructive'
65
+ disabled?: boolean
66
+ onSelect: () => void
67
+ }
68
+
69
+ export interface DropdownButtonProps {
70
+ /** Trigger label (e.g., "New Keyword Plan"). */
71
+ label: string
72
+ /** Trigger variant — defaults to primary to match the screenshot. */
73
+ variant?: ButtonVariant
74
+ /** Trigger size — passes through to the .btn size class. */
75
+ size?: ButtonSize
76
+ /** Optional leading icon on the trigger (e.g., a `+`). */
77
+ leadingIcon?: ReactNode
78
+ /** Override the default chevron-down trailing icon if needed. */
79
+ trailingIcon?: ReactNode
80
+ /** Menu items rendered in order. */
81
+ items: DropdownButtonItem[]
82
+ /** Disable the trigger entirely. */
83
+ disabled?: boolean
84
+ /** Show the .is-loading state on the trigger. */
85
+ loading?: boolean
86
+ /** Forwarded to the trigger's className for layout overrides. */
87
+ className?: string
88
+ /** Popover alignment relative to the trigger. Default `end`
89
+ * (right-aligned) to match the reference screenshot. */
90
+ align?: DropdownMenuContentProps['align']
91
+ /** Popover side — `top` or `bottom`. Default `bottom`. */
92
+ side?: DropdownMenuContentProps['side']
93
+ /** Popover gap from the trigger. Default 6. */
94
+ sideOffset?: DropdownMenuContentProps['sideOffset']
95
+ /** Constrain the popover width. Default `min(20rem, calc(100vw-16px))`. */
96
+ menuWidth?: string
97
+ /** Optional aria-label for the trigger if the visible label is
98
+ * ambiguous (e.g., icon-only triggers). */
99
+ triggerAriaLabel?: string
100
+ /** Optional id of the currently-active item. When set, that item
101
+ * renders a trailing check mark — turns the DropdownButton into a
102
+ * single-select filter/picker. */
103
+ activeId?: string
104
+ /** When true, the trigger gets an accent-tinted treatment — signals
105
+ * an engaged filter. Pair with `activeId` for filter-chip use. */
106
+ active?: boolean
107
+ }
108
+
109
+ export function DropdownButton({
110
+ label,
111
+ variant = 'primary',
112
+ size = 'md',
113
+ leadingIcon,
114
+ trailingIcon,
115
+ items,
116
+ disabled,
117
+ loading,
118
+ className,
119
+ align = 'end',
120
+ side = 'bottom',
121
+ sideOffset = 6,
122
+ menuWidth = 'min(20rem, calc(100vw - 16px))',
123
+ triggerAriaLabel,
124
+ activeId,
125
+ active,
126
+ }: DropdownButtonProps) {
127
+ const reactId = useId()
128
+
129
+ return (
130
+ <DropdownMenu>
131
+ <DropdownMenuTrigger
132
+ unstyled
133
+ disabled={disabled || loading}
134
+ aria-label={triggerAriaLabel}
135
+ className={cn(
136
+ 'btn',
137
+ VARIANT_CLASS[variant],
138
+ SIZE_CLASS[size],
139
+ loading && 'is-loading',
140
+ className,
141
+ )}
142
+ style={
143
+ active
144
+ ? {
145
+ background: 'rgb(var(--accent-subtle))',
146
+ borderColor: 'rgb(var(--accent))',
147
+ color: 'rgb(var(--accent))',
148
+ }
149
+ : undefined
150
+ }
151
+ >
152
+ {leadingIcon}
153
+ <span>{label}</span>
154
+ {trailingIcon ?? (
155
+ <ChevronDown className="h-4 w-4" aria-hidden="true" />
156
+ )}
157
+ </DropdownMenuTrigger>
158
+ <DropdownMenuContent
159
+ align={align}
160
+ side={side}
161
+ sideOffset={sideOffset}
162
+ style={{ width: menuWidth, padding: 6 }}
163
+ >
164
+ {items.map((item, index) => {
165
+ const isActive =
166
+ activeId !== undefined && item.id === activeId
167
+ return (
168
+ <DropdownMenuItem
169
+ key={item.id ?? `${reactId}-${index}`}
170
+ {...(item.variant ? { variant: item.variant } : {})}
171
+ {...(item.disabled ? { disabled: item.disabled } : {})}
172
+ onClick={item.onSelect}
173
+ className="!py-2.5 !px-2.5 !gap-3 items-start"
174
+ >
175
+ {item.icon && (
176
+ <span
177
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center"
178
+ style={{
179
+ background: 'rgb(var(--surface-overlay))',
180
+ color: 'rgb(var(--text-muted))',
181
+ borderRadius: 'var(--radius-md)',
182
+ }}
183
+ aria-hidden="true"
184
+ >
185
+ {item.icon}
186
+ </span>
187
+ )}
188
+ <span className="flex flex-col gap-0.5 min-w-0 flex-1">
189
+ <span
190
+ className={cn(
191
+ 'text-[13px] font-semibold leading-tight',
192
+ item.variant === 'destructive'
193
+ ? 'text-[rgb(var(--destructive))]'
194
+ : 'text-[rgb(var(--foreground))]',
195
+ )}
196
+ >
197
+ {item.label}
198
+ </span>
199
+ {item.description && (
200
+ <span
201
+ className="text-[12px] leading-[1.4]"
202
+ style={{ color: 'rgb(var(--text-tertiary))' }}
203
+ >
204
+ {item.description}
205
+ </span>
206
+ )}
207
+ </span>
208
+ {isActive && (
209
+ <Check
210
+ className="h-4 w-4 shrink-0 mt-0.5"
211
+ style={{ color: 'rgb(var(--accent))' }}
212
+ aria-hidden="true"
213
+ />
214
+ )}
215
+ </DropdownMenuItem>
216
+ )
217
+ })}
218
+ </DropdownMenuContent>
219
+ </DropdownMenu>
220
+ )
221
+ }