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