@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,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProfileSection — section primitive with the head/tray/foot anatomy
|
|
3
|
+
* used by the redesigned Brand Profile page (and reusable on any
|
|
4
|
+
* settings-style page with multiple labelled, scrollable sections).
|
|
5
|
+
*
|
|
6
|
+
* Built on `ds-card-surface` with `data-framed="true"` so it inherits
|
|
7
|
+
* the same chrome as Card / MediaCard / StatCard — slate frame around
|
|
8
|
+
* a bright focal tray, with the foot sitting back on the frame.
|
|
9
|
+
*
|
|
10
|
+
* Two head modes:
|
|
11
|
+
* • Static (default) — pass `actions` (e.g. Edit / Generate buttons).
|
|
12
|
+
* • Editable — pass `editable` props (onSave / onCancel / isSaving)
|
|
13
|
+
* and the head shows an Edit pencil in view mode, swapping to
|
|
14
|
+
* Cancel + Save buttons in edit mode. Tray content can branch on
|
|
15
|
+
* the render-prop child: `{(isEditing) => isEditing ? <Form/> : <View/>}`.
|
|
16
|
+
*
|
|
17
|
+
* Sections expose `data-section-id="<id>"` so ChipNav can scroll-spy.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
cloneElement,
|
|
22
|
+
isValidElement,
|
|
23
|
+
useState,
|
|
24
|
+
type ButtonHTMLAttributes,
|
|
25
|
+
type ReactElement,
|
|
26
|
+
type ReactNode,
|
|
27
|
+
} from 'react'
|
|
28
|
+
import { Check, Loader2, Pencil, X } from 'lucide-react'
|
|
29
|
+
import { cn } from './lib/utils'
|
|
30
|
+
|
|
31
|
+
// Canonical size for the head icon. Centralised so every section uses
|
|
32
|
+
// the same glyph size — no more `<Compass className="h-[17px] w-[17px]" />`
|
|
33
|
+
// at seven callsites.
|
|
34
|
+
const HEAD_ICON_SIZE = 17
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Section head — icon + title + subtitle + actions
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
interface SectionHeadProps {
|
|
41
|
+
icon?: ReactNode | undefined
|
|
42
|
+
title: string
|
|
43
|
+
subtitle?: string | undefined
|
|
44
|
+
actions?: ReactNode | undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function SectionHead({ icon, title, subtitle, actions }: SectionHeadProps) {
|
|
48
|
+
const sizedIcon = isValidElement(icon)
|
|
49
|
+
? cloneElement(
|
|
50
|
+
icon as ReactElement<{ className?: string; size?: number | string }>,
|
|
51
|
+
{
|
|
52
|
+
size: HEAD_ICON_SIZE,
|
|
53
|
+
className: cn(
|
|
54
|
+
(icon.props as { className?: string }).className,
|
|
55
|
+
`h-[${HEAD_ICON_SIZE}px] w-[${HEAD_ICON_SIZE}px]`,
|
|
56
|
+
),
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
: icon
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="flex items-center gap-3 px-4 pt-4 pb-3">
|
|
63
|
+
{icon && (
|
|
64
|
+
<span
|
|
65
|
+
className="shrink-0 inline-flex items-center justify-center w-[34px] h-[34px] rounded-[10px]"
|
|
66
|
+
style={{
|
|
67
|
+
background: 'rgb(var(--accent-muted))',
|
|
68
|
+
color: 'rgb(var(--accent))',
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
{sizedIcon}
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
<div className="flex-1 min-w-0">
|
|
75
|
+
<div
|
|
76
|
+
className="text-[14.5px] font-semibold leading-tight tracking-[-0.01em] truncate"
|
|
77
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
78
|
+
>
|
|
79
|
+
{title}
|
|
80
|
+
</div>
|
|
81
|
+
{subtitle && (
|
|
82
|
+
<div
|
|
83
|
+
className="text-[12px] mt-[3px] leading-snug"
|
|
84
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
85
|
+
>
|
|
86
|
+
{subtitle}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
{actions && (
|
|
91
|
+
<div className="shrink-0 flex items-center gap-1">{actions}</div>
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Action button — small icon button used in the section head
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
interface ActionButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
102
|
+
label: string
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ActionButton({
|
|
106
|
+
label,
|
|
107
|
+
className,
|
|
108
|
+
children,
|
|
109
|
+
...rest
|
|
110
|
+
}: ActionButtonProps) {
|
|
111
|
+
return (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
title={label}
|
|
115
|
+
aria-label={label}
|
|
116
|
+
className={cn(
|
|
117
|
+
'inline-flex items-center justify-center w-[30px] h-[30px] rounded-[8px] transition-colors cursor-pointer',
|
|
118
|
+
className,
|
|
119
|
+
)}
|
|
120
|
+
style={{
|
|
121
|
+
background: 'transparent',
|
|
122
|
+
color: 'rgb(var(--text-tertiary))',
|
|
123
|
+
border: '1px solid transparent',
|
|
124
|
+
}}
|
|
125
|
+
onMouseEnter={(e) => {
|
|
126
|
+
e.currentTarget.style.background = 'rgb(var(--surface-overlay))'
|
|
127
|
+
e.currentTarget.style.color = 'rgb(var(--foreground))'
|
|
128
|
+
e.currentTarget.style.borderColor = 'rgb(var(--border))'
|
|
129
|
+
}}
|
|
130
|
+
onMouseLeave={(e) => {
|
|
131
|
+
e.currentTarget.style.background = 'transparent'
|
|
132
|
+
e.currentTarget.style.color = 'rgb(var(--text-tertiary))'
|
|
133
|
+
e.currentTarget.style.borderColor = 'transparent'
|
|
134
|
+
}}
|
|
135
|
+
{...rest}
|
|
136
|
+
>
|
|
137
|
+
{children}
|
|
138
|
+
</button>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Foot — meta items + optional AI-action link
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
interface FootProps {
|
|
147
|
+
meta?: ReactNode
|
|
148
|
+
link?: { label: string; onClick: () => void; icon?: ReactNode }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Foot({ meta, link }: FootProps) {
|
|
152
|
+
if (!meta && !link) return null
|
|
153
|
+
return (
|
|
154
|
+
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-4 text-[12px]">
|
|
155
|
+
<div
|
|
156
|
+
className="flex items-center gap-3.5 flex-wrap"
|
|
157
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
158
|
+
>
|
|
159
|
+
{meta}
|
|
160
|
+
</div>
|
|
161
|
+
{link && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={link.onClick}
|
|
165
|
+
className="inline-flex items-center gap-1 text-[11.5px] font-medium bg-transparent border-0 cursor-pointer p-0 transition-opacity"
|
|
166
|
+
style={{
|
|
167
|
+
color: 'rgb(var(--accent))',
|
|
168
|
+
fontFamily: 'var(--font-sans)',
|
|
169
|
+
}}
|
|
170
|
+
onMouseEnter={(e) => (e.currentTarget.style.textDecoration = 'underline')}
|
|
171
|
+
onMouseLeave={(e) => (e.currentTarget.style.textDecoration = 'none')}
|
|
172
|
+
>
|
|
173
|
+
{link.label}
|
|
174
|
+
{link.icon}
|
|
175
|
+
</button>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** FootItem — a single icon+text item inside the foot. */
|
|
182
|
+
function FootItem({ children }: { children: ReactNode }) {
|
|
183
|
+
return <span className="inline-flex items-center gap-1.5">{children}</span>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Editable head — pencil → cancel/save controls
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
interface EditableHeadActionsProps {
|
|
191
|
+
isEditing: boolean
|
|
192
|
+
isSaving: boolean
|
|
193
|
+
saveDisabled?: boolean | undefined
|
|
194
|
+
onEdit: () => void
|
|
195
|
+
onCancel: () => void
|
|
196
|
+
onSave: () => void
|
|
197
|
+
extraViewActions?: ReactNode | undefined
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function EditableHeadActions({
|
|
201
|
+
isEditing,
|
|
202
|
+
isSaving,
|
|
203
|
+
saveDisabled,
|
|
204
|
+
onEdit,
|
|
205
|
+
onCancel,
|
|
206
|
+
onSave,
|
|
207
|
+
extraViewActions,
|
|
208
|
+
}: EditableHeadActionsProps) {
|
|
209
|
+
if (isEditing) {
|
|
210
|
+
return (
|
|
211
|
+
<>
|
|
212
|
+
<ActionButton
|
|
213
|
+
label="Cancel"
|
|
214
|
+
onClick={onCancel}
|
|
215
|
+
disabled={isSaving}
|
|
216
|
+
style={
|
|
217
|
+
isSaving
|
|
218
|
+
? { opacity: 0.5, pointerEvents: 'none' }
|
|
219
|
+
: undefined
|
|
220
|
+
}
|
|
221
|
+
>
|
|
222
|
+
<X className="h-4 w-4" />
|
|
223
|
+
</ActionButton>
|
|
224
|
+
<ActionButton
|
|
225
|
+
label={isSaving ? 'Saving…' : 'Save'}
|
|
226
|
+
onClick={onSave}
|
|
227
|
+
disabled={isSaving || saveDisabled}
|
|
228
|
+
style={
|
|
229
|
+
isSaving || saveDisabled
|
|
230
|
+
? { opacity: 0.5, pointerEvents: 'none' }
|
|
231
|
+
: { color: 'rgb(var(--accent))' }
|
|
232
|
+
}
|
|
233
|
+
>
|
|
234
|
+
{isSaving ? (
|
|
235
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
236
|
+
) : (
|
|
237
|
+
<Check className="h-4 w-4" />
|
|
238
|
+
)}
|
|
239
|
+
</ActionButton>
|
|
240
|
+
</>
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
return (
|
|
244
|
+
<>
|
|
245
|
+
{extraViewActions}
|
|
246
|
+
<ActionButton label="Edit" onClick={onEdit}>
|
|
247
|
+
<Pencil className="h-[14px] w-[14px]" />
|
|
248
|
+
</ActionButton>
|
|
249
|
+
</>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// ProfileSection root
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
interface BaseProps {
|
|
258
|
+
/** DOM id and `data-section-id` for ChipNav scroll-spy. */
|
|
259
|
+
id: string
|
|
260
|
+
/** Lucide icon (rendered ~17px). */
|
|
261
|
+
icon?: ReactNode
|
|
262
|
+
/** Section title. */
|
|
263
|
+
title: string
|
|
264
|
+
/** Section subtitle / description. */
|
|
265
|
+
subtitle?: string
|
|
266
|
+
/** Optional foot meta + AI-action link. */
|
|
267
|
+
foot?: FootProps
|
|
268
|
+
/** Extra padding tweak. */
|
|
269
|
+
className?: string
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
interface StaticSectionProps extends BaseProps {
|
|
273
|
+
/** Right-side header actions (override default pencil/generate). */
|
|
274
|
+
actions?: ReactNode
|
|
275
|
+
children: ReactNode
|
|
276
|
+
editable?: undefined
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
interface EditableSectionProps extends BaseProps {
|
|
280
|
+
editable: {
|
|
281
|
+
onSave: () => void | Promise<void>
|
|
282
|
+
onCancel?: () => void
|
|
283
|
+
isSaving?: boolean
|
|
284
|
+
saveDisabled?: boolean
|
|
285
|
+
/** Controlled editing state. If omitted, state is internal. */
|
|
286
|
+
isEditing?: boolean
|
|
287
|
+
onEditingChange?: (editing: boolean) => void
|
|
288
|
+
/** Extra actions before the pencil in view mode (e.g. Generate). */
|
|
289
|
+
extraViewActions?: ReactNode
|
|
290
|
+
}
|
|
291
|
+
/** Render-prop child receives the current editing state. */
|
|
292
|
+
children: (isEditing: boolean) => ReactNode
|
|
293
|
+
actions?: undefined
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
type ProfileSectionProps = StaticSectionProps | EditableSectionProps
|
|
297
|
+
|
|
298
|
+
function ProfileSection(props: ProfileSectionProps) {
|
|
299
|
+
const { id, icon, title, subtitle, foot, className } = props
|
|
300
|
+
const [internalEditing, setInternalEditing] = useState(false)
|
|
301
|
+
|
|
302
|
+
const editable = 'editable' in props ? props.editable : undefined
|
|
303
|
+
const isEditing = editable
|
|
304
|
+
? editable.isEditing ?? internalEditing
|
|
305
|
+
: false
|
|
306
|
+
|
|
307
|
+
const setEditing = (v: boolean) => {
|
|
308
|
+
if (editable?.onEditingChange) editable.onEditingChange(v)
|
|
309
|
+
else setInternalEditing(v)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const handleCancel = () => {
|
|
313
|
+
editable?.onCancel?.()
|
|
314
|
+
setEditing(false)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const handleSave = async () => {
|
|
318
|
+
if (!editable) return
|
|
319
|
+
try {
|
|
320
|
+
await editable.onSave()
|
|
321
|
+
setEditing(false)
|
|
322
|
+
} catch {
|
|
323
|
+
// Caller surfaces the error via toast / mutation state.
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const headActions = editable ? (
|
|
328
|
+
<EditableHeadActions
|
|
329
|
+
isEditing={isEditing}
|
|
330
|
+
isSaving={!!editable.isSaving}
|
|
331
|
+
saveDisabled={editable.saveDisabled}
|
|
332
|
+
onEdit={() => setEditing(true)}
|
|
333
|
+
onCancel={handleCancel}
|
|
334
|
+
onSave={handleSave}
|
|
335
|
+
extraViewActions={editable.extraViewActions}
|
|
336
|
+
/>
|
|
337
|
+
) : (
|
|
338
|
+
props.actions ?? (
|
|
339
|
+
<ActionButton label="Edit">
|
|
340
|
+
<Pencil className="h-[14px] w-[14px]" />
|
|
341
|
+
</ActionButton>
|
|
342
|
+
)
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
const children = editable
|
|
346
|
+
? (props.children as (e: boolean) => ReactNode)(isEditing)
|
|
347
|
+
: (props.children as ReactNode)
|
|
348
|
+
|
|
349
|
+
return (
|
|
350
|
+
<section
|
|
351
|
+
id={id}
|
|
352
|
+
data-section-id={id}
|
|
353
|
+
data-framed="true"
|
|
354
|
+
className={cn('ds-card-surface flex flex-col', className)}
|
|
355
|
+
style={{ scrollMarginTop: 'var(--section-scroll-offset)' }}
|
|
356
|
+
>
|
|
357
|
+
<SectionHead
|
|
358
|
+
icon={icon}
|
|
359
|
+
title={title}
|
|
360
|
+
subtitle={subtitle}
|
|
361
|
+
actions={headActions}
|
|
362
|
+
/>
|
|
363
|
+
{/* flex:1 lets the tray fill remaining height when the section is
|
|
364
|
+
stretched (e.g. side-by-side in an equal-height grid), so trays
|
|
365
|
+
bottom-align instead of leaving a gap under a shorter-header card.
|
|
366
|
+
No effect on un-stretched (vertically-stacked) sections. */}
|
|
367
|
+
<div className="ds-card-tray p-5" style={{ flex: '1 1 auto' }}>
|
|
368
|
+
{children}
|
|
369
|
+
</div>
|
|
370
|
+
{foot && <Foot {...foot} />}
|
|
371
|
+
</section>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
// Compose
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
type ProfileSectionComponent = typeof ProfileSection & {
|
|
380
|
+
ActionButton: typeof ActionButton
|
|
381
|
+
FootItem: typeof FootItem
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const PS = ProfileSection as ProfileSectionComponent
|
|
385
|
+
PS.ActionButton = ActionButton
|
|
386
|
+
PS.FootItem = FootItem
|
|
387
|
+
|
|
388
|
+
export default PS
|
|
389
|
+
export type { ProfileSectionProps }
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ProgressBar — labeled fill bar.
|
|
3
|
+
*
|
|
4
|
+
* Promoted in ADR-020 (Phase 3a Discovery report generation, first
|
|
5
|
+
* committed consumer). Cross-batch consumers: Phase 8 (AI-suggest
|
|
6
|
+
* progress if surfaced), Phase 12 (generation step in wizard).
|
|
7
|
+
*
|
|
8
|
+
* Indeterminate variant for unknown-duration progress (animated
|
|
9
|
+
* stripe sweep); determinate variant for known-percent progress.
|
|
10
|
+
*
|
|
11
|
+
* Token discipline: all colors via public tokens.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ProgressBarProps {
|
|
15
|
+
/** Optional label above the bar. */
|
|
16
|
+
label?: string
|
|
17
|
+
/** 0-100 percent (determinate). Omit for indeterminate animation. */
|
|
18
|
+
percent?: number
|
|
19
|
+
/** Optional secondary text shown right of the label. Often "X%" or a status message. */
|
|
20
|
+
status?: string
|
|
21
|
+
/** Track height in px (default 8). */
|
|
22
|
+
height?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function ProgressBar({ label, percent, status, height = 8 }: ProgressBarProps) {
|
|
26
|
+
const determinate = typeof percent === 'number'
|
|
27
|
+
const clamped = determinate ? Math.max(0, Math.min(100, percent)) : 0
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex flex-col gap-1.5">
|
|
31
|
+
{(label || status) && (
|
|
32
|
+
<div className="flex items-center justify-between">
|
|
33
|
+
{label && (
|
|
34
|
+
<span
|
|
35
|
+
className="text-[12px] font-medium"
|
|
36
|
+
style={{ color: 'rgb(var(--text-secondary))' }}
|
|
37
|
+
>
|
|
38
|
+
{label}
|
|
39
|
+
</span>
|
|
40
|
+
)}
|
|
41
|
+
{status && (
|
|
42
|
+
<span
|
|
43
|
+
className="text-[11px] tabular-nums"
|
|
44
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
45
|
+
>
|
|
46
|
+
{status}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
)}
|
|
51
|
+
<div
|
|
52
|
+
role="progressbar"
|
|
53
|
+
aria-valuenow={determinate ? clamped : undefined}
|
|
54
|
+
aria-valuemin={0}
|
|
55
|
+
aria-valuemax={100}
|
|
56
|
+
className="w-full overflow-hidden"
|
|
57
|
+
style={{
|
|
58
|
+
height,
|
|
59
|
+
background: 'rgb(var(--surface-overlay))',
|
|
60
|
+
borderRadius: 'var(--radius-full)',
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
{determinate ? (
|
|
64
|
+
<div
|
|
65
|
+
className="h-full transition-[width] duration-300 ease-out"
|
|
66
|
+
style={{
|
|
67
|
+
width: `${clamped}%`,
|
|
68
|
+
background: 'rgb(var(--accent))',
|
|
69
|
+
borderRadius: 'var(--radius-full)',
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
) : (
|
|
73
|
+
<div
|
|
74
|
+
className="h-full progress-indeterminate"
|
|
75
|
+
style={{
|
|
76
|
+
background: 'rgb(var(--accent))',
|
|
77
|
+
borderRadius: 'var(--radius-full)',
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
</div>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
}
|
package/src/radio.tsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Radio + RadioGroup primitives (ADR-029 D3).
|
|
3
|
+
*
|
|
4
|
+
* Stand-alone radio inputs for cases where the OptionTile pattern is
|
|
5
|
+
* too visual — inline form labels, compact mode pickers. For tinted-
|
|
6
|
+
* card mode pickers use <OptionTileGroup type="radio"> instead.
|
|
7
|
+
*
|
|
8
|
+
* API mirrors RadioGroup conventions:
|
|
9
|
+
* <RadioGroup name="case" value={mode} onChange={setMode}>
|
|
10
|
+
* <Radio value="lower" label="lowercase" />
|
|
11
|
+
* <Radio value="upper" label="UPPERCASE" description="all caps" />
|
|
12
|
+
* </RadioGroup>
|
|
13
|
+
*
|
|
14
|
+
* Token discipline: all public tokens (--accent, --border, --foreground,
|
|
15
|
+
* --text-tertiary, --ring-focus). Composable inside <Field>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
createContext,
|
|
20
|
+
forwardRef,
|
|
21
|
+
useContext,
|
|
22
|
+
useId,
|
|
23
|
+
type ReactNode,
|
|
24
|
+
} from 'react'
|
|
25
|
+
import { cn } from './lib/utils'
|
|
26
|
+
|
|
27
|
+
export interface RadioGroupProps<T extends string = string> {
|
|
28
|
+
/** Form name — required for proper radio semantics + form submission. */
|
|
29
|
+
name: string
|
|
30
|
+
/** Currently selected value. */
|
|
31
|
+
value: T | null
|
|
32
|
+
/** Fires when the user picks a different option. */
|
|
33
|
+
onChange: (next: T) => void
|
|
34
|
+
/** <Radio> children. */
|
|
35
|
+
children: ReactNode
|
|
36
|
+
/** Visually-hidden label for screen readers. Renders as <legend>. */
|
|
37
|
+
ariaLabel?: string
|
|
38
|
+
/** Layout direction. Defaults to vertical. */
|
|
39
|
+
orientation?: 'horizontal' | 'vertical'
|
|
40
|
+
/** Extra classes on the wrapping <fieldset>. */
|
|
41
|
+
className?: string
|
|
42
|
+
/** Disable every child radio. */
|
|
43
|
+
disabled?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface RadioGroupContextValue {
|
|
47
|
+
name: string
|
|
48
|
+
value: string | null
|
|
49
|
+
onChange: (next: string) => void
|
|
50
|
+
disabled: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const RadioGroupContext = createContext<RadioGroupContextValue | null>(null)
|
|
54
|
+
|
|
55
|
+
export function RadioGroup<T extends string = string>({
|
|
56
|
+
name,
|
|
57
|
+
value,
|
|
58
|
+
onChange,
|
|
59
|
+
children,
|
|
60
|
+
ariaLabel,
|
|
61
|
+
orientation = 'vertical',
|
|
62
|
+
className,
|
|
63
|
+
disabled = false,
|
|
64
|
+
}: RadioGroupProps<T>) {
|
|
65
|
+
const ctx: RadioGroupContextValue = {
|
|
66
|
+
name,
|
|
67
|
+
value,
|
|
68
|
+
onChange: onChange as (next: string) => void,
|
|
69
|
+
disabled,
|
|
70
|
+
}
|
|
71
|
+
return (
|
|
72
|
+
<RadioGroupContext.Provider value={ctx}>
|
|
73
|
+
<fieldset
|
|
74
|
+
className={cn(
|
|
75
|
+
'flex',
|
|
76
|
+
orientation === 'horizontal' ? 'flex-row gap-4' : 'flex-col gap-2',
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{ariaLabel && <legend className="sr-only">{ariaLabel}</legend>}
|
|
81
|
+
{children}
|
|
82
|
+
</fieldset>
|
|
83
|
+
</RadioGroupContext.Provider>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface RadioProps {
|
|
88
|
+
/** Value emitted to the group's onChange when picked. */
|
|
89
|
+
value: string
|
|
90
|
+
/** Visible label. */
|
|
91
|
+
label: ReactNode
|
|
92
|
+
/** Optional secondary text (smaller, --text-tertiary). */
|
|
93
|
+
description?: ReactNode
|
|
94
|
+
/** Disable this specific radio (overrides group default). */
|
|
95
|
+
disabled?: boolean
|
|
96
|
+
/** Extra classes on the outer <label>. */
|
|
97
|
+
className?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const Radio = forwardRef<HTMLInputElement, RadioProps>(function Radio(
|
|
101
|
+
{ value, label, description, disabled, className },
|
|
102
|
+
ref,
|
|
103
|
+
) {
|
|
104
|
+
const ctx = useContext(RadioGroupContext)
|
|
105
|
+
if (!ctx) {
|
|
106
|
+
throw new Error('<Radio> must be rendered inside <RadioGroup>')
|
|
107
|
+
}
|
|
108
|
+
const inputId = useId()
|
|
109
|
+
const isSelected = ctx.value === value
|
|
110
|
+
const isDisabled = disabled ?? ctx.disabled
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<label
|
|
114
|
+
htmlFor={inputId}
|
|
115
|
+
className={cn(
|
|
116
|
+
'inline-flex items-start gap-2.5 cursor-pointer select-none group',
|
|
117
|
+
isDisabled && 'cursor-not-allowed opacity-50',
|
|
118
|
+
className,
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
<input
|
|
122
|
+
ref={ref}
|
|
123
|
+
id={inputId}
|
|
124
|
+
type="radio"
|
|
125
|
+
name={ctx.name}
|
|
126
|
+
value={value}
|
|
127
|
+
checked={isSelected}
|
|
128
|
+
disabled={isDisabled}
|
|
129
|
+
onChange={() => ctx.onChange(value)}
|
|
130
|
+
className="peer sr-only"
|
|
131
|
+
/>
|
|
132
|
+
{/* Visual dot — 16px outer ring, 8px inner fill. */}
|
|
133
|
+
<span
|
|
134
|
+
aria-hidden
|
|
135
|
+
className={cn(
|
|
136
|
+
'mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full transition-colors',
|
|
137
|
+
'peer-focus-visible:[box-shadow:var(--ring-focus)]',
|
|
138
|
+
)}
|
|
139
|
+
style={{
|
|
140
|
+
border: `1.5px solid rgb(var(${
|
|
141
|
+
isSelected ? '--accent' : '--border-strong'
|
|
142
|
+
}))`,
|
|
143
|
+
background: 'transparent',
|
|
144
|
+
}}
|
|
145
|
+
>
|
|
146
|
+
<span
|
|
147
|
+
className={cn(
|
|
148
|
+
'h-2 w-2 rounded-full transition-opacity',
|
|
149
|
+
isSelected ? 'opacity-100' : 'opacity-0',
|
|
150
|
+
)}
|
|
151
|
+
style={{ background: 'rgb(var(--accent))' }}
|
|
152
|
+
/>
|
|
153
|
+
</span>
|
|
154
|
+
<span className="flex flex-col gap-0.5 min-w-0">
|
|
155
|
+
<span
|
|
156
|
+
className="text-[13px] font-medium"
|
|
157
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
158
|
+
>
|
|
159
|
+
{label}
|
|
160
|
+
</span>
|
|
161
|
+
{description && (
|
|
162
|
+
<span
|
|
163
|
+
className="text-[12px]"
|
|
164
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
165
|
+
>
|
|
166
|
+
{description}
|
|
167
|
+
</span>
|
|
168
|
+
)}
|
|
169
|
+
</span>
|
|
170
|
+
</label>
|
|
171
|
+
)
|
|
172
|
+
})
|