@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
package/src/card.tsx
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
forwardRef,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type HTMLAttributes,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from 'react'
|
|
8
|
+
import { cn } from './lib/utils'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Card — head/body/foot anatomy.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* <Card interactive onClick={...}>
|
|
15
|
+
* <Card.Head>
|
|
16
|
+
* <IconTile bg={...} fg={...}><Icon/></IconTile>
|
|
17
|
+
* <div className="flex-1 min-w-0">
|
|
18
|
+
* <Card.Title>Roof Replacement</Card.Title>
|
|
19
|
+
* <Card.Desc>Full tear-off and replacement...</Card.Desc>
|
|
20
|
+
* </div>
|
|
21
|
+
* </Card.Head>
|
|
22
|
+
* <Card.Body>
|
|
23
|
+
* <TagRow tags={['25-Year Warranty', 'Metal & Asphalt']}/>
|
|
24
|
+
* </Card.Body>
|
|
25
|
+
* <Card.Foot>
|
|
26
|
+
* <Card.Meta>...</Card.Meta>
|
|
27
|
+
* <Card.Actions>...</Card.Actions>
|
|
28
|
+
* </Card.Foot>
|
|
29
|
+
* </Card>
|
|
30
|
+
*
|
|
31
|
+
* `interactive` adds cursor + hover lift + reveal-on-hover for Card.Actions.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
type DivProps = HTMLAttributes<HTMLDivElement>
|
|
35
|
+
|
|
36
|
+
interface CardProps extends DivProps {
|
|
37
|
+
interactive?: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const CardRoot = forwardRef<HTMLDivElement, CardProps>(function Card(
|
|
41
|
+
{ className, interactive, children, ...rest },
|
|
42
|
+
ref,
|
|
43
|
+
) {
|
|
44
|
+
// Auto-wrap Head + Body (and any sibling content) into a single
|
|
45
|
+
// tray, with Foot left outside on the outer frame. This gives every
|
|
46
|
+
// card the canonical "gray frame + white focal panel + footer on
|
|
47
|
+
// frame" look without callers needing to restructure their JSX.
|
|
48
|
+
//
|
|
49
|
+
// Two opt-outs:
|
|
50
|
+
// 1. If a child is <Card.Tray> directly, the caller is managing
|
|
51
|
+
// tray layout themselves (asset card pattern) — render flat.
|
|
52
|
+
// 2. If no child is <Card.Head>/<Card.Body>/<Card.Foot>, this is
|
|
53
|
+
// a "custom content" card (e.g. brand-profile logo, history row,
|
|
54
|
+
// mini stat) — render flat so the auto-wrap doesn't add unwanted
|
|
55
|
+
// inset margin to small or one-off layouts.
|
|
56
|
+
const childArray = Children.toArray(children)
|
|
57
|
+
const hasExplicitTray = childArray.some(
|
|
58
|
+
(child) => isValidElement(child) && child.type === Tray,
|
|
59
|
+
)
|
|
60
|
+
const hasNamedSlot = childArray.some(
|
|
61
|
+
(child) =>
|
|
62
|
+
isValidElement(child) &&
|
|
63
|
+
(child.type === Head || child.type === Body || child.type === Foot),
|
|
64
|
+
)
|
|
65
|
+
const shouldAutoWrap = !hasExplicitTray && hasNamedSlot
|
|
66
|
+
|
|
67
|
+
let renderedChildren: ReactNode = children
|
|
68
|
+
if (shouldAutoWrap) {
|
|
69
|
+
const trayChildren: ReactNode[] = []
|
|
70
|
+
const footChildren: ReactNode[] = []
|
|
71
|
+
childArray.forEach((child) => {
|
|
72
|
+
if (isValidElement(child) && child.type === Foot) {
|
|
73
|
+
footChildren.push(child)
|
|
74
|
+
} else {
|
|
75
|
+
trayChildren.push(child)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
renderedChildren = (
|
|
79
|
+
<>
|
|
80
|
+
{trayChildren.length > 0 && (
|
|
81
|
+
<div className="ds-card-tray flex-1">{trayChildren}</div>
|
|
82
|
+
)}
|
|
83
|
+
{footChildren}
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div
|
|
90
|
+
ref={ref}
|
|
91
|
+
data-interactive={interactive ? 'true' : undefined}
|
|
92
|
+
data-framed={shouldAutoWrap ? 'true' : undefined}
|
|
93
|
+
className={cn(
|
|
94
|
+
'ds-card-surface group/card flex flex-col h-full overflow-hidden',
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
{...rest}
|
|
98
|
+
>
|
|
99
|
+
{renderedChildren}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
function Head({ className, ...rest }: DivProps) {
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
className={cn('flex items-start gap-3 px-5 pt-5', className)}
|
|
108
|
+
{...rest}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function Body({ className, ...rest }: DivProps) {
|
|
114
|
+
return <div className={cn('px-5 py-3 pb-4', className)} {...rest} />
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function Tray({ className, ...rest }: DivProps) {
|
|
118
|
+
return <div className={cn('ds-card-tray', className)} {...rest} />
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function Foot({ className, ...rest }: DivProps) {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
className={cn(
|
|
125
|
+
'mt-auto flex items-center justify-between gap-3 px-5 py-3 text-[12px]',
|
|
126
|
+
className,
|
|
127
|
+
)}
|
|
128
|
+
style={{
|
|
129
|
+
color: 'rgb(var(--text-tertiary))',
|
|
130
|
+
...rest.style,
|
|
131
|
+
}}
|
|
132
|
+
{...rest}
|
|
133
|
+
/>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Title({
|
|
138
|
+
children,
|
|
139
|
+
className,
|
|
140
|
+
...rest
|
|
141
|
+
}: HTMLAttributes<HTMLDivElement>) {
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
className={cn(
|
|
145
|
+
'text-[15px] font-semibold leading-[1.3] truncate flex-1 min-w-0',
|
|
146
|
+
className,
|
|
147
|
+
)}
|
|
148
|
+
style={{ color: 'rgb(var(--foreground))', ...rest.style }}
|
|
149
|
+
{...rest}
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</div>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function Desc({
|
|
157
|
+
children,
|
|
158
|
+
className,
|
|
159
|
+
...rest
|
|
160
|
+
}: HTMLAttributes<HTMLDivElement> & { clamp?: 2 | 3 }) {
|
|
161
|
+
const clamp = (rest as { clamp?: 2 | 3 }).clamp ?? 2
|
|
162
|
+
return (
|
|
163
|
+
<div
|
|
164
|
+
className={cn(
|
|
165
|
+
'text-[13px] leading-[1.5] mt-1.5',
|
|
166
|
+
clamp === 2 && 'line-clamp-2',
|
|
167
|
+
clamp === 3 && 'line-clamp-3',
|
|
168
|
+
className,
|
|
169
|
+
)}
|
|
170
|
+
style={{ color: 'rgb(var(--text-secondary))', ...rest.style }}
|
|
171
|
+
{...rest}
|
|
172
|
+
>
|
|
173
|
+
{children}
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function Meta({ className, ...rest }: DivProps) {
|
|
179
|
+
return (
|
|
180
|
+
<div
|
|
181
|
+
className={cn('flex items-center gap-3 min-w-0', className)}
|
|
182
|
+
{...rest}
|
|
183
|
+
/>
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function MetaItem({
|
|
188
|
+
icon,
|
|
189
|
+
children,
|
|
190
|
+
className,
|
|
191
|
+
}: {
|
|
192
|
+
icon?: ReactNode
|
|
193
|
+
children: ReactNode
|
|
194
|
+
className?: string
|
|
195
|
+
}) {
|
|
196
|
+
return (
|
|
197
|
+
<div
|
|
198
|
+
className={cn('flex items-center gap-1.5 text-[12px]', className)}
|
|
199
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
200
|
+
>
|
|
201
|
+
{icon && (
|
|
202
|
+
<span style={{ opacity: 0.7 }} className="shrink-0 inline-flex">
|
|
203
|
+
{icon}
|
|
204
|
+
</span>
|
|
205
|
+
)}
|
|
206
|
+
<span className="truncate tnum">{children}</span>
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function Actions({ className, ...rest }: DivProps) {
|
|
212
|
+
return (
|
|
213
|
+
<div
|
|
214
|
+
className={cn('flex items-center gap-1', className)}
|
|
215
|
+
onClick={(e) => e.stopPropagation()}
|
|
216
|
+
{...rest}
|
|
217
|
+
/>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface ActionButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
|
222
|
+
danger?: boolean
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const ActionButton = forwardRef<HTMLButtonElement, ActionButtonProps>(
|
|
226
|
+
function ActionButton({ className, danger, children, ...rest }, ref) {
|
|
227
|
+
return (
|
|
228
|
+
<button
|
|
229
|
+
ref={ref}
|
|
230
|
+
className={cn(
|
|
231
|
+
'flex h-7 w-7 items-center justify-center rounded-[6px] transition-colors',
|
|
232
|
+
className,
|
|
233
|
+
)}
|
|
234
|
+
style={{
|
|
235
|
+
background: 'transparent',
|
|
236
|
+
color: 'rgb(var(--text-tertiary))',
|
|
237
|
+
border: '1px solid rgb(var(--border))',
|
|
238
|
+
}}
|
|
239
|
+
onMouseEnter={(e) => {
|
|
240
|
+
if (danger) {
|
|
241
|
+
e.currentTarget.style.color = 'rgb(var(--destructive))'
|
|
242
|
+
e.currentTarget.style.borderColor = 'rgb(var(--destructive) / 0.4)'
|
|
243
|
+
e.currentTarget.style.background =
|
|
244
|
+
'rgb(var(--destructive) / 0.08)'
|
|
245
|
+
} else {
|
|
246
|
+
e.currentTarget.style.color = 'rgb(var(--foreground))'
|
|
247
|
+
e.currentTarget.style.borderColor = 'rgb(var(--border-strong))'
|
|
248
|
+
e.currentTarget.style.background = 'rgb(var(--surface-overlay))'
|
|
249
|
+
}
|
|
250
|
+
}}
|
|
251
|
+
onMouseLeave={(e) => {
|
|
252
|
+
e.currentTarget.style.color = 'rgb(var(--text-tertiary))'
|
|
253
|
+
e.currentTarget.style.borderColor = 'rgb(var(--border))'
|
|
254
|
+
e.currentTarget.style.background = 'transparent'
|
|
255
|
+
}}
|
|
256
|
+
{...rest}
|
|
257
|
+
>
|
|
258
|
+
{children}
|
|
259
|
+
</button>
|
|
260
|
+
)
|
|
261
|
+
},
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Icon-on-color tile used in card heads. Pass tone colors as inline rgb()
|
|
266
|
+
* strings so they can reference semantic tokens (info/success/etc.).
|
|
267
|
+
*/
|
|
268
|
+
function IconTile({
|
|
269
|
+
bg,
|
|
270
|
+
fg,
|
|
271
|
+
size = 38,
|
|
272
|
+
className,
|
|
273
|
+
children,
|
|
274
|
+
}: {
|
|
275
|
+
bg: string
|
|
276
|
+
fg: string
|
|
277
|
+
size?: number
|
|
278
|
+
className?: string
|
|
279
|
+
children: ReactNode
|
|
280
|
+
}) {
|
|
281
|
+
return (
|
|
282
|
+
<span
|
|
283
|
+
className={cn(
|
|
284
|
+
'inline-flex items-center justify-center rounded-[10px] shrink-0',
|
|
285
|
+
className,
|
|
286
|
+
)}
|
|
287
|
+
style={{
|
|
288
|
+
width: size,
|
|
289
|
+
height: size,
|
|
290
|
+
background: bg,
|
|
291
|
+
color: fg,
|
|
292
|
+
}}
|
|
293
|
+
>
|
|
294
|
+
{children}
|
|
295
|
+
</span>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Tag row helper — reads `--tag-bg`/`--border` automatically. */
|
|
300
|
+
function TagRow({
|
|
301
|
+
tags,
|
|
302
|
+
max = 4,
|
|
303
|
+
className,
|
|
304
|
+
}: {
|
|
305
|
+
tags: string[]
|
|
306
|
+
max?: number
|
|
307
|
+
className?: string
|
|
308
|
+
}) {
|
|
309
|
+
const shown = tags.slice(0, max)
|
|
310
|
+
const overflow = tags.length - max
|
|
311
|
+
return (
|
|
312
|
+
<div className={cn('flex flex-wrap gap-1.5', className)}>
|
|
313
|
+
{shown.map((t, i) => (
|
|
314
|
+
<span
|
|
315
|
+
key={i}
|
|
316
|
+
className="inline-flex items-center px-2.5 py-[3px] rounded-full text-[11.5px] font-medium whitespace-nowrap"
|
|
317
|
+
style={{
|
|
318
|
+
background: 'rgb(var(--surface-overlay))',
|
|
319
|
+
border: '1px solid rgb(var(--border))',
|
|
320
|
+
color: 'rgb(var(--text-secondary))',
|
|
321
|
+
}}
|
|
322
|
+
>
|
|
323
|
+
{t}
|
|
324
|
+
</span>
|
|
325
|
+
))}
|
|
326
|
+
{overflow > 0 && (
|
|
327
|
+
<span
|
|
328
|
+
className="text-[11px] font-semibold self-center"
|
|
329
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
330
|
+
>
|
|
331
|
+
+{overflow}
|
|
332
|
+
</span>
|
|
333
|
+
)}
|
|
334
|
+
</div>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Compose the API
|
|
339
|
+
type CardComponent = typeof CardRoot & {
|
|
340
|
+
Head: typeof Head
|
|
341
|
+
Body: typeof Body
|
|
342
|
+
Tray: typeof Tray
|
|
343
|
+
Foot: typeof Foot
|
|
344
|
+
Title: typeof Title
|
|
345
|
+
Desc: typeof Desc
|
|
346
|
+
Meta: typeof Meta
|
|
347
|
+
MetaItem: typeof MetaItem
|
|
348
|
+
Actions: typeof Actions
|
|
349
|
+
ActionButton: typeof ActionButton
|
|
350
|
+
IconTile: typeof IconTile
|
|
351
|
+
TagRow: typeof TagRow
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const Card = CardRoot as CardComponent
|
|
355
|
+
Card.Head = Head
|
|
356
|
+
Card.Body = Body
|
|
357
|
+
Card.Tray = Tray
|
|
358
|
+
Card.Foot = Foot
|
|
359
|
+
Card.Title = Title
|
|
360
|
+
Card.Desc = Desc
|
|
361
|
+
Card.Meta = Meta
|
|
362
|
+
Card.MetaItem = MetaItem
|
|
363
|
+
Card.Actions = Actions
|
|
364
|
+
Card.ActionButton = ActionButton
|
|
365
|
+
Card.IconTile = IconTile
|
|
366
|
+
Card.TagRow = TagRow
|
|
367
|
+
|
|
368
|
+
export default Card
|
|
369
|
+
export { IconTile, TagRow }
|
package/src/checkbox.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkbox — workspace-native checkbox primitive.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by gray-ui-csm `components/ui/checkbox.tsx` (MIT,
|
|
5
|
+
* https://github.com/Jason-uxui/gray-ui-csm/blob/baf8346ac0c1160282e0b173de1bffc594d9c9c3/components/ui/checkbox.tsx)
|
|
6
|
+
* — see packages/ui/licenses/gray-ui-csm-LICENSE.txt.
|
|
7
|
+
*
|
|
8
|
+
* BUILD_FRESH per ADR-025 DEVIATION-2: source ships on
|
|
9
|
+
* `@base-ui/react/checkbox`, which is NOT in CLAUDE.md §1 allowed
|
|
10
|
+
* deps. The shape (icon-in-square + indeterminate state +
|
|
11
|
+
* accessible Root API) and the surface API (`checked`,
|
|
12
|
+
* `indeterminate`, `onCheckedChange`) carry over; the implementation
|
|
13
|
+
* is a native `<input type="checkbox">` styled with our tokens.
|
|
14
|
+
*
|
|
15
|
+
* Workspace ADR: ADR-025
|
|
16
|
+
* Ported 2026-05-18
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
forwardRef,
|
|
21
|
+
useEffect,
|
|
22
|
+
useRef,
|
|
23
|
+
type InputHTMLAttributes,
|
|
24
|
+
} from 'react'
|
|
25
|
+
import { Check, Minus } from 'lucide-react'
|
|
26
|
+
import { cn } from './lib/utils'
|
|
27
|
+
|
|
28
|
+
export interface CheckboxProps
|
|
29
|
+
extends Omit<
|
|
30
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
31
|
+
'type' | 'onChange' | 'checked'
|
|
32
|
+
> {
|
|
33
|
+
checked?: boolean
|
|
34
|
+
indeterminate?: boolean
|
|
35
|
+
onCheckedChange?: (checked: boolean) => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
|
39
|
+
function Checkbox(
|
|
40
|
+
{ checked, indeterminate, onCheckedChange, className, disabled, ...rest },
|
|
41
|
+
forwardedRef,
|
|
42
|
+
) {
|
|
43
|
+
const internalRef = useRef<HTMLInputElement | null>(null)
|
|
44
|
+
|
|
45
|
+
// Compose forwarded ref + internal ref so we can drive
|
|
46
|
+
// `.indeterminate` (a DOM-only property — not reflected as an
|
|
47
|
+
// attribute and not settable via React props).
|
|
48
|
+
const setRef = (node: HTMLInputElement | null) => {
|
|
49
|
+
internalRef.current = node
|
|
50
|
+
if (typeof forwardedRef === 'function') forwardedRef(node)
|
|
51
|
+
else if (forwardedRef)
|
|
52
|
+
(forwardedRef as { current: HTMLInputElement | null }).current = node
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (internalRef.current) {
|
|
57
|
+
internalRef.current.indeterminate = Boolean(indeterminate)
|
|
58
|
+
}
|
|
59
|
+
}, [indeterminate])
|
|
60
|
+
|
|
61
|
+
const isChecked = Boolean(checked)
|
|
62
|
+
const isIndeterminate = Boolean(indeterminate)
|
|
63
|
+
const showIcon = isChecked || isIndeterminate
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<span
|
|
67
|
+
data-slot="checkbox"
|
|
68
|
+
data-state={
|
|
69
|
+
isIndeterminate
|
|
70
|
+
? 'indeterminate'
|
|
71
|
+
: isChecked
|
|
72
|
+
? 'checked'
|
|
73
|
+
: 'unchecked'
|
|
74
|
+
}
|
|
75
|
+
className={cn(
|
|
76
|
+
'relative inline-flex items-center justify-center shrink-0',
|
|
77
|
+
'h-4 w-4 rounded-[4px]',
|
|
78
|
+
'transition-colors',
|
|
79
|
+
disabled && 'opacity-50 cursor-not-allowed',
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
style={{
|
|
83
|
+
background: showIcon
|
|
84
|
+
? 'rgb(var(--accent))'
|
|
85
|
+
: 'rgb(var(--bg-input))',
|
|
86
|
+
border: showIcon
|
|
87
|
+
? '1.5px solid rgb(var(--accent))'
|
|
88
|
+
: '1.5px solid rgb(var(--border-strong))',
|
|
89
|
+
// Checkmark on the brand-red fill — matches the `.btn-primary`
|
|
90
|
+
// pattern in styles.css (`color: white`) — fixed white reads
|
|
91
|
+
// cleanly in both light and dark mode on the accent fill.
|
|
92
|
+
color: 'white',
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<input
|
|
96
|
+
ref={setRef}
|
|
97
|
+
type="checkbox"
|
|
98
|
+
checked={isChecked}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
onChange={(event) => onCheckedChange?.(event.target.checked)}
|
|
101
|
+
className="absolute inset-0 cursor-pointer opacity-0 disabled:cursor-not-allowed"
|
|
102
|
+
{...rest}
|
|
103
|
+
/>
|
|
104
|
+
{showIcon ? (
|
|
105
|
+
isIndeterminate ? (
|
|
106
|
+
<Minus className="h-3 w-3 pointer-events-none" strokeWidth={3} />
|
|
107
|
+
) : (
|
|
108
|
+
<Check className="h-3 w-3 pointer-events-none" strokeWidth={3} />
|
|
109
|
+
)
|
|
110
|
+
) : null}
|
|
111
|
+
</span>
|
|
112
|
+
)
|
|
113
|
+
},
|
|
114
|
+
)
|