@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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrandLogoTile — the rounded-square logo container used wherever the
|
|
3
|
+
* app shows a brand's identity at a glance: the brand profile rail
|
|
4
|
+
* (78px), the sidebar switcher button (36px), dropdown rows (28px),
|
|
5
|
+
* the dashboard hero tile, etc.
|
|
6
|
+
*
|
|
7
|
+
* <BrandLogoTile size="lg" url={brand.logoUrl} name={brand.name} />
|
|
8
|
+
* <BrandLogoTile size={56} url={...} name={...} />
|
|
9
|
+
* <BrandLogoTile size="md" name="Acme" /> // initial-only
|
|
10
|
+
*
|
|
11
|
+
* Sizes are tokens (sm/md/lg) but custom numbers work too — radius
|
|
12
|
+
* scales proportionally so a 64px tile looks right without manual
|
|
13
|
+
* tuning. Pass `accentGlow` to add the subtle radial halo used on the
|
|
14
|
+
* brand profile rail tray.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
|
|
18
|
+
import { cn } from './lib/utils'
|
|
19
|
+
|
|
20
|
+
export type BrandLogoTileSize = 'xs' | 'sm' | 'md' | 'lg' | number
|
|
21
|
+
|
|
22
|
+
// Named sizes map to the most common diameters in the app. Keep
|
|
23
|
+
// these in sync with patterns.md when adding entries.
|
|
24
|
+
const SIZE_PX: Record<Exclude<BrandLogoTileSize, number>, number> = {
|
|
25
|
+
xs: 28, // dropdown rows
|
|
26
|
+
sm: 36, // collapsed sidebar
|
|
27
|
+
md: 56, // dashboard hero
|
|
28
|
+
lg: 78, // brand profile rail
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resolveSize(size: BrandLogoTileSize): number {
|
|
32
|
+
return typeof size === 'number' ? size : SIZE_PX[size]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Radius scales with diameter so every size looks like the same shape
|
|
37
|
+
* family. Coefficient 0.23 chosen to match the rail's 78px → 18px ratio.
|
|
38
|
+
* Floored to whole px since sub-pixel radii render fuzzy on some GPUs.
|
|
39
|
+
*/
|
|
40
|
+
function radiusFor(size: number): number {
|
|
41
|
+
return Math.max(6, Math.floor(size * 0.23))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Inner padding for image children — bigger for full logos so the
|
|
45
|
+
* glyph doesn't kiss the edges, zero for tiny favicons that need to
|
|
46
|
+
* fill the tile. */
|
|
47
|
+
function paddingFor(size: number, isFavicon: boolean): number {
|
|
48
|
+
if (isFavicon) return 0
|
|
49
|
+
if (size <= 36) return 4
|
|
50
|
+
if (size <= 56) return 8
|
|
51
|
+
return 10
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface BrandLogoTileProps {
|
|
55
|
+
/** Size token or a raw pixel number. Defaults to `md` (56px). */
|
|
56
|
+
size?: BrandLogoTileSize
|
|
57
|
+
/** Image URL — logo, favicon, anything `<img>` can render. */
|
|
58
|
+
url?: string | null
|
|
59
|
+
/**
|
|
60
|
+
* Brand name. Used as `alt` text and as the source for the
|
|
61
|
+
* single-character fallback when no `url` is provided (or the image
|
|
62
|
+
* fails to load).
|
|
63
|
+
*/
|
|
64
|
+
name?: string
|
|
65
|
+
/**
|
|
66
|
+
* Treat the image as a favicon — render it smaller, centred, without
|
|
67
|
+
* stretching to fill the tile. Auto-detected when the URL starts
|
|
68
|
+
* with the Google s2 favicon endpoint; pass `true`/`false` to override.
|
|
69
|
+
*/
|
|
70
|
+
isFavicon?: boolean
|
|
71
|
+
/**
|
|
72
|
+
* Adds a subtle accent-coloured radial glow above the tile. Used on
|
|
73
|
+
* the brand profile rail to give the logo a sense of presence
|
|
74
|
+
* against the slate frame.
|
|
75
|
+
*/
|
|
76
|
+
accentGlow?: boolean
|
|
77
|
+
/** Override the fallback character (defaults to `name[0]`). */
|
|
78
|
+
fallbackChar?: ReactNode
|
|
79
|
+
/**
|
|
80
|
+
* Override the tile background. Use for contexts that have their own
|
|
81
|
+
* visual identity (e.g. the sidebar switcher's blue gradient). When
|
|
82
|
+
* omitted, the tile picks up the neutral card→overlay gradient that
|
|
83
|
+
* blends with surrounding chrome.
|
|
84
|
+
*/
|
|
85
|
+
background?: string
|
|
86
|
+
/**
|
|
87
|
+
* Override the fallback character / image-area text colour. Pair
|
|
88
|
+
* with `background` for branded variants (white text on coloured
|
|
89
|
+
* gradient). Defaults to the accent token.
|
|
90
|
+
*/
|
|
91
|
+
color?: string
|
|
92
|
+
/** Drop the 1px border (used when the tile sits on a strongly tinted bg). */
|
|
93
|
+
borderless?: boolean
|
|
94
|
+
className?: string
|
|
95
|
+
style?: CSSProperties
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export default function BrandLogoTile({
|
|
99
|
+
size = 'md',
|
|
100
|
+
url,
|
|
101
|
+
name,
|
|
102
|
+
isFavicon,
|
|
103
|
+
accentGlow,
|
|
104
|
+
fallbackChar,
|
|
105
|
+
background,
|
|
106
|
+
color,
|
|
107
|
+
borderless,
|
|
108
|
+
className,
|
|
109
|
+
style,
|
|
110
|
+
}: BrandLogoTileProps) {
|
|
111
|
+
const px = resolveSize(size)
|
|
112
|
+
const radius = radiusFor(px)
|
|
113
|
+
const fav = isFavicon ?? url?.includes('s2/favicons') ?? false
|
|
114
|
+
const pad = paddingFor(px, fav)
|
|
115
|
+
// Initial text scales with the tile so a 78px tile gets a big 34-ish
|
|
116
|
+
// glyph and a 28px tile gets a 12-ish one. Tuned against the rail
|
|
117
|
+
// (78 → 34) — coefficient ≈ 0.44.
|
|
118
|
+
const initialFontSize = Math.max(11, Math.round(px * 0.44))
|
|
119
|
+
|
|
120
|
+
const initial = (
|
|
121
|
+
typeof fallbackChar === 'string'
|
|
122
|
+
? fallbackChar
|
|
123
|
+
: (name?.charAt(0).toUpperCase() ?? '?')
|
|
124
|
+
) as ReactNode
|
|
125
|
+
|
|
126
|
+
// Favicon/logo images that fail to load (404, blocked, dead host) fall
|
|
127
|
+
// back to the initial. We track the error in state so the <img> is
|
|
128
|
+
// *replaced* by the letter span — hiding the broken img alone left an
|
|
129
|
+
// empty tile (the letter only renders in the no-url branch). The flag
|
|
130
|
+
// resets when `url` changes so a reused tile re-attempts the new image.
|
|
131
|
+
const [imgError, setImgError] = useState(false)
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
setImgError(false)
|
|
134
|
+
}, [url])
|
|
135
|
+
const showImg = !!url && !imgError
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
className={cn(
|
|
140
|
+
'relative inline-flex items-center justify-center overflow-hidden shrink-0',
|
|
141
|
+
className,
|
|
142
|
+
)}
|
|
143
|
+
style={{
|
|
144
|
+
width: px,
|
|
145
|
+
height: px,
|
|
146
|
+
borderRadius: radius,
|
|
147
|
+
background:
|
|
148
|
+
background ??
|
|
149
|
+
'linear-gradient(180deg, rgb(var(--bg-card)) 0%, rgb(var(--surface-overlay)) 100%)',
|
|
150
|
+
border: borderless ? 'none' : '1px solid rgb(var(--border))',
|
|
151
|
+
color: color ?? 'rgb(var(--accent))',
|
|
152
|
+
boxShadow: borderless ? undefined : 'var(--shadow-sm)',
|
|
153
|
+
...style,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{accentGlow && (
|
|
157
|
+
<span
|
|
158
|
+
aria-hidden
|
|
159
|
+
className="absolute pointer-events-none"
|
|
160
|
+
style={{
|
|
161
|
+
top: -Math.round(px * 0.5),
|
|
162
|
+
left: '50%',
|
|
163
|
+
transform: 'translateX(-50%)',
|
|
164
|
+
width: Math.round(px * 2.6),
|
|
165
|
+
height: Math.round(px * 1.05),
|
|
166
|
+
background:
|
|
167
|
+
'radial-gradient(closest-side, rgb(var(--accent) / 0.18), transparent 70%)',
|
|
168
|
+
}}
|
|
169
|
+
/>
|
|
170
|
+
)}
|
|
171
|
+
{showImg ? (
|
|
172
|
+
<img
|
|
173
|
+
src={url ?? undefined}
|
|
174
|
+
alt={name ?? ''}
|
|
175
|
+
className="object-contain"
|
|
176
|
+
style={{
|
|
177
|
+
width: fav ? '60%' : '100%',
|
|
178
|
+
height: fav ? '60%' : '100%',
|
|
179
|
+
padding: pad,
|
|
180
|
+
}}
|
|
181
|
+
onError={() => {
|
|
182
|
+
// Fall back to the initial silently — swap the img for the
|
|
183
|
+
// fallback character (see imgError note above).
|
|
184
|
+
setImgError(true)
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
) : (
|
|
188
|
+
<span
|
|
189
|
+
className="font-extrabold leading-none"
|
|
190
|
+
style={{
|
|
191
|
+
fontSize: initialFontSize,
|
|
192
|
+
letterSpacing: '-0.04em',
|
|
193
|
+
}}
|
|
194
|
+
>
|
|
195
|
+
{fallbackChar ?? initial}
|
|
196
|
+
</span>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BulkActionBar — floating, bottom-centred selection action bar.
|
|
3
|
+
*
|
|
4
|
+
* Shown when a multi-select surface has a non-empty selection. The
|
|
5
|
+
* primitive owns the fixed bottom-centre shell, the accent count
|
|
6
|
+
* badge, the dividers, and the Clear button; the caller passes its
|
|
7
|
+
* own action buttons as `children`.
|
|
8
|
+
*
|
|
9
|
+
* Promoted to @lovett/ui in ADR-035 (Assets lens redesign) — two
|
|
10
|
+
* committed consumers (the Assets page and the Manage Folders page)
|
|
11
|
+
* and it needs the internal surface tokens (`--bg-card` /
|
|
12
|
+
* `--border-card`).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ReactNode } from 'react'
|
|
16
|
+
import { X } from 'lucide-react'
|
|
17
|
+
|
|
18
|
+
export interface BulkActionBarProps {
|
|
19
|
+
/** Selection count shown in the accent badge. */
|
|
20
|
+
count: number
|
|
21
|
+
/** Optional summary string, e.g. "2 folders · 3 assets". When
|
|
22
|
+
* omitted, a plain "<count> selected" label is shown. */
|
|
23
|
+
summary?: string
|
|
24
|
+
/** Caller-owned action buttons (icon buttons or labelled Buttons). */
|
|
25
|
+
children: ReactNode
|
|
26
|
+
onClear: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Divider() {
|
|
30
|
+
return (
|
|
31
|
+
<span
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
className="h-[22px] w-px shrink-0"
|
|
34
|
+
style={{ background: 'rgb(var(--border))' }}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function BulkActionBar({
|
|
40
|
+
count,
|
|
41
|
+
summary,
|
|
42
|
+
children,
|
|
43
|
+
onClear,
|
|
44
|
+
}: BulkActionBarProps) {
|
|
45
|
+
return (
|
|
46
|
+
<div
|
|
47
|
+
role="region"
|
|
48
|
+
aria-label="Bulk actions"
|
|
49
|
+
className="fixed bottom-6 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2.5 py-2.5 pl-4 pr-3"
|
|
50
|
+
style={{
|
|
51
|
+
background: 'rgb(var(--bg-card))',
|
|
52
|
+
border: '1px solid rgb(var(--border-card))',
|
|
53
|
+
borderRadius: 'var(--radius-lg)',
|
|
54
|
+
boxShadow: 'var(--shadow-lg)',
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
<span className="inline-flex items-center gap-2">
|
|
58
|
+
<span
|
|
59
|
+
className="inline-flex h-[22px] min-w-[22px] items-center justify-center px-1.5 text-[11.5px] font-bold tabular-nums text-white"
|
|
60
|
+
style={{
|
|
61
|
+
background: 'rgb(var(--accent))',
|
|
62
|
+
borderRadius: 'var(--radius-full)',
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{count}
|
|
66
|
+
</span>
|
|
67
|
+
<span
|
|
68
|
+
className="text-[13px] font-semibold"
|
|
69
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
70
|
+
>
|
|
71
|
+
{summary ?? 'selected'}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
|
|
75
|
+
<Divider />
|
|
76
|
+
|
|
77
|
+
<div className="flex items-center gap-1.5">{children}</div>
|
|
78
|
+
|
|
79
|
+
<Divider />
|
|
80
|
+
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={onClear}
|
|
84
|
+
className="inline-flex h-7 items-center gap-1.5 px-2.5 cursor-pointer text-[12px] font-medium transition-colors"
|
|
85
|
+
style={{
|
|
86
|
+
background: 'transparent',
|
|
87
|
+
color: 'rgb(var(--text-tertiary))',
|
|
88
|
+
borderRadius: 'var(--radius-sm)',
|
|
89
|
+
}}
|
|
90
|
+
onMouseEnter={(e) => {
|
|
91
|
+
e.currentTarget.style.background = 'rgb(var(--surface-overlay))'
|
|
92
|
+
e.currentTarget.style.color = 'rgb(var(--foreground))'
|
|
93
|
+
}}
|
|
94
|
+
onMouseLeave={(e) => {
|
|
95
|
+
e.currentTarget.style.background = 'transparent'
|
|
96
|
+
e.currentTarget.style.color = 'rgb(var(--text-tertiary))'
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
|
100
|
+
Clear
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ButtonGroup — typed wrapper over the `.btn-group` CSS class in styles.css.
|
|
3
|
+
*
|
|
4
|
+
* Renders Button children as a joined segmented control (single-row,
|
|
5
|
+
* shared borders, rounded outer corners). Used for view-switchers,
|
|
6
|
+
* mode toggles, and other "pick one of N" affordances where the
|
|
7
|
+
* options should read as a connected group.
|
|
8
|
+
*
|
|
9
|
+
* Promoted from inline CSS-class usage to a typed primitive per
|
|
10
|
+
* ADR-026 D5 (inspired by gray-ui-csm recon §13 DQ-5 — the
|
|
11
|
+
* view-switcher button group).
|
|
12
|
+
*
|
|
13
|
+
* <ButtonGroup aria-label="View mode">
|
|
14
|
+
* <Button variant="secondary" aria-pressed={view === 'grid'}>
|
|
15
|
+
* <Grid /> Grid
|
|
16
|
+
* </Button>
|
|
17
|
+
* <Button variant="secondary" aria-pressed={view === 'list'}>
|
|
18
|
+
* <List /> List
|
|
19
|
+
* </Button>
|
|
20
|
+
* </ButtonGroup>
|
|
21
|
+
*
|
|
22
|
+
* The first/last child get rounded outer corners; inner segments are
|
|
23
|
+
* square. Inner borders deduplicate via `border-right-width: 0` on
|
|
24
|
+
* non-last children.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { HTMLAttributes, ReactNode } from 'react'
|
|
28
|
+
import { cn } from './lib/utils'
|
|
29
|
+
|
|
30
|
+
export interface ButtonGroupProps extends HTMLAttributes<HTMLDivElement> {
|
|
31
|
+
/** One or more `<Button>` elements. */
|
|
32
|
+
children: ReactNode
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ButtonGroup({
|
|
36
|
+
children,
|
|
37
|
+
className,
|
|
38
|
+
role = 'group',
|
|
39
|
+
...rest
|
|
40
|
+
}: ButtonGroupProps) {
|
|
41
|
+
return (
|
|
42
|
+
<div role={role} className={cn('btn-group', className)} {...rest}>
|
|
43
|
+
{children}
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|
package/src/button.tsx
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Button — typed primitive over the .btn CSS classes in styles.css.
|
|
3
|
+
*
|
|
4
|
+
* variants: primary | secondary | outline | ghost | destructive |
|
|
5
|
+
* destructive-soft | icon | toolbar
|
|
6
|
+
* sizes: sm (32) | md (40) — default | lg (48)
|
|
7
|
+
* shape: default | circle (use with variant="icon" — ADR-026 D3)
|
|
8
|
+
* states: loading, disabled — both forwarded to the CSS class
|
|
9
|
+
*/
|
|
10
|
+
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'
|
|
11
|
+
import { cn } from './lib/utils'
|
|
12
|
+
|
|
13
|
+
export type ButtonVariant =
|
|
14
|
+
| 'primary'
|
|
15
|
+
| 'secondary'
|
|
16
|
+
| 'outline'
|
|
17
|
+
| 'ghost'
|
|
18
|
+
| 'destructive'
|
|
19
|
+
| 'destructive-soft'
|
|
20
|
+
| 'icon'
|
|
21
|
+
| 'toolbar'
|
|
22
|
+
|
|
23
|
+
export type ButtonSize = 'sm' | 'md' | 'lg'
|
|
24
|
+
|
|
25
|
+
export type ButtonShape = 'default' | 'circle'
|
|
26
|
+
|
|
27
|
+
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
|
28
|
+
primary: 'btn-primary',
|
|
29
|
+
secondary: 'btn-secondary',
|
|
30
|
+
outline: 'btn-outline',
|
|
31
|
+
ghost: 'btn-ghost',
|
|
32
|
+
destructive: 'btn-destructive',
|
|
33
|
+
'destructive-soft': 'btn-destructive-soft',
|
|
34
|
+
icon: 'btn-icon',
|
|
35
|
+
toolbar: 'btn-toolbar',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SIZE_CLASS: Record<ButtonSize, string> = {
|
|
39
|
+
sm: 'size-sm',
|
|
40
|
+
md: '',
|
|
41
|
+
lg: 'size-lg',
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
45
|
+
variant?: ButtonVariant
|
|
46
|
+
size?: ButtonSize
|
|
47
|
+
/** ADR-026 D3 — `circle` only takes effect when `variant="icon"`. */
|
|
48
|
+
shape?: ButtonShape
|
|
49
|
+
/** Show the loading spinner overlay; auto-disables the button. */
|
|
50
|
+
loading?: boolean
|
|
51
|
+
/** Optional left-side icon. Forces icon-text gap from .btn. */
|
|
52
|
+
leadingIcon?: ReactNode
|
|
53
|
+
/** Optional right-side icon. */
|
|
54
|
+
trailingIcon?: ReactNode
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
58
|
+
{
|
|
59
|
+
variant = 'primary',
|
|
60
|
+
size = 'md',
|
|
61
|
+
shape = 'default',
|
|
62
|
+
loading,
|
|
63
|
+
leadingIcon,
|
|
64
|
+
trailingIcon,
|
|
65
|
+
disabled,
|
|
66
|
+
className,
|
|
67
|
+
children,
|
|
68
|
+
type = 'button',
|
|
69
|
+
...rest
|
|
70
|
+
},
|
|
71
|
+
ref,
|
|
72
|
+
) {
|
|
73
|
+
return (
|
|
74
|
+
<button
|
|
75
|
+
ref={ref}
|
|
76
|
+
type={type}
|
|
77
|
+
disabled={disabled || loading}
|
|
78
|
+
className={cn(
|
|
79
|
+
'btn',
|
|
80
|
+
VARIANT_CLASS[variant],
|
|
81
|
+
SIZE_CLASS[size],
|
|
82
|
+
shape === 'circle' && variant === 'icon' && 'is-circle',
|
|
83
|
+
loading && 'is-loading',
|
|
84
|
+
className,
|
|
85
|
+
)}
|
|
86
|
+
{...rest}
|
|
87
|
+
>
|
|
88
|
+
{leadingIcon}
|
|
89
|
+
{children}
|
|
90
|
+
{trailingIcon}
|
|
91
|
+
</button>
|
|
92
|
+
)
|
|
93
|
+
})
|