@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,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OptionTile + OptionTileGroup primitives (ADR-029 D2).
|
|
3
|
+
*
|
|
4
|
+
* Tinted-card-with-input pattern — clickable card surface that wraps a
|
|
5
|
+
* native radio OR checkbox input, with accent fill + border when active
|
|
6
|
+
* and an optional preview/description sub-line.
|
|
7
|
+
*
|
|
8
|
+
* Replaces 4 lens-local forks of the same idiom across Convert Case
|
|
9
|
+
* (route + modal), Text Formatter, and the Replace/Dedupe modals.
|
|
10
|
+
*
|
|
11
|
+
* Group usage (recommended):
|
|
12
|
+
* <OptionTileGroup
|
|
13
|
+
* type="radio"
|
|
14
|
+
* name="case-mode"
|
|
15
|
+
* value={mode}
|
|
16
|
+
* onChange={setMode}
|
|
17
|
+
* options={[
|
|
18
|
+
* { value: 'lower', label: 'lowercase', description: 'all small letters' },
|
|
19
|
+
* { value: 'upper', label: 'UPPERCASE' },
|
|
20
|
+
* ]}
|
|
21
|
+
* />
|
|
22
|
+
*
|
|
23
|
+
* <OptionTileGroup
|
|
24
|
+
* type="checkbox"
|
|
25
|
+
* value={enabledOps}
|
|
26
|
+
* onChange={setEnabledOps}
|
|
27
|
+
* options={...}
|
|
28
|
+
* />
|
|
29
|
+
*
|
|
30
|
+
* Standalone usage (escape hatch for non-group layouts):
|
|
31
|
+
* <OptionTile
|
|
32
|
+
* selected={mode === 'lower'}
|
|
33
|
+
* onSelect={() => setMode('lower')}
|
|
34
|
+
* inputType="radio"
|
|
35
|
+
* inputName="case-mode"
|
|
36
|
+
* label="lowercase"
|
|
37
|
+
* description="all small letters"
|
|
38
|
+
* />
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { useId, type ReactNode } from 'react'
|
|
42
|
+
import { cn } from './lib/utils'
|
|
43
|
+
|
|
44
|
+
// ─── single tile ─────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface OptionTileProps {
|
|
47
|
+
selected: boolean
|
|
48
|
+
onSelect: () => void
|
|
49
|
+
inputType: 'radio' | 'checkbox'
|
|
50
|
+
/** Required for radio (shared name across the group). */
|
|
51
|
+
inputName: string | undefined
|
|
52
|
+
label: ReactNode
|
|
53
|
+
description: ReactNode | undefined
|
|
54
|
+
disabled: boolean | undefined
|
|
55
|
+
className: string | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function OptionTile({
|
|
59
|
+
selected,
|
|
60
|
+
onSelect,
|
|
61
|
+
inputType,
|
|
62
|
+
inputName,
|
|
63
|
+
label,
|
|
64
|
+
description,
|
|
65
|
+
disabled = false,
|
|
66
|
+
className,
|
|
67
|
+
}: OptionTileProps) {
|
|
68
|
+
const inputId = useId()
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<label
|
|
72
|
+
htmlFor={inputId}
|
|
73
|
+
className={cn(
|
|
74
|
+
'flex items-start gap-2.5 px-3 py-2.5 cursor-pointer select-none transition-colors',
|
|
75
|
+
'focus-within:[box-shadow:var(--ring-focus)]',
|
|
76
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
style={{
|
|
80
|
+
borderRadius: 'var(--radius-md)',
|
|
81
|
+
background: selected
|
|
82
|
+
? 'rgb(var(--accent-subtle))'
|
|
83
|
+
: 'rgb(var(--surface-overlay-soft))',
|
|
84
|
+
border: `1px solid rgb(var(${
|
|
85
|
+
selected ? '--accent-muted' : '--border'
|
|
86
|
+
}))`,
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<input
|
|
90
|
+
id={inputId}
|
|
91
|
+
type={inputType}
|
|
92
|
+
name={inputName}
|
|
93
|
+
checked={selected}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
onChange={onSelect}
|
|
96
|
+
className="sr-only"
|
|
97
|
+
/>
|
|
98
|
+
{/* Visual indicator — dot for radio, check for checkbox. */}
|
|
99
|
+
<span
|
|
100
|
+
aria-hidden
|
|
101
|
+
className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center"
|
|
102
|
+
style={{
|
|
103
|
+
borderRadius: inputType === 'radio' ? '9999px' : '4px',
|
|
104
|
+
border: `1.5px solid rgb(var(${
|
|
105
|
+
selected ? '--accent' : '--border-strong'
|
|
106
|
+
}))`,
|
|
107
|
+
background:
|
|
108
|
+
selected && inputType === 'checkbox'
|
|
109
|
+
? 'rgb(var(--accent))'
|
|
110
|
+
: 'transparent',
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{selected && inputType === 'radio' && (
|
|
114
|
+
<span
|
|
115
|
+
className="h-2 w-2 rounded-full"
|
|
116
|
+
style={{ background: 'rgb(var(--accent))' }}
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
{selected && inputType === 'checkbox' && (
|
|
120
|
+
<CheckGlyph />
|
|
121
|
+
)}
|
|
122
|
+
</span>
|
|
123
|
+
<span className="flex flex-col gap-0.5 min-w-0 flex-1">
|
|
124
|
+
<span
|
|
125
|
+
className="text-[13px] font-medium leading-tight"
|
|
126
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
127
|
+
>
|
|
128
|
+
{label}
|
|
129
|
+
</span>
|
|
130
|
+
{description && (
|
|
131
|
+
<span
|
|
132
|
+
className="text-[12px] leading-snug"
|
|
133
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
134
|
+
>
|
|
135
|
+
{description}
|
|
136
|
+
</span>
|
|
137
|
+
)}
|
|
138
|
+
</span>
|
|
139
|
+
</label>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function CheckGlyph() {
|
|
144
|
+
// Lucide-style check, rendered as inline SVG path inside a primitive.
|
|
145
|
+
// Kept inline because the parent <span> already provides the colored
|
|
146
|
+
// background — we just need the white tick on top. NOT a violation of
|
|
147
|
+
// CLAUDE.md §6 (icon discipline only forbids inline SVG OUTSIDE
|
|
148
|
+
// packages/ui — primitives in this package may render their own
|
|
149
|
+
// canonical glyphs).
|
|
150
|
+
return (
|
|
151
|
+
<svg
|
|
152
|
+
width="10"
|
|
153
|
+
height="10"
|
|
154
|
+
viewBox="0 0 24 24"
|
|
155
|
+
fill="none"
|
|
156
|
+
stroke="white"
|
|
157
|
+
strokeWidth="3"
|
|
158
|
+
strokeLinecap="round"
|
|
159
|
+
strokeLinejoin="round"
|
|
160
|
+
>
|
|
161
|
+
<polyline points="20 6 9 17 4 12" />
|
|
162
|
+
</svg>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── group wrapper ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
interface OptionTileGroupOption<T extends string> {
|
|
169
|
+
value: T
|
|
170
|
+
label: ReactNode
|
|
171
|
+
description?: ReactNode
|
|
172
|
+
disabled?: boolean
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Discriminated by `type` so TS narrows value + onChange shapes correctly.
|
|
176
|
+
export type OptionTileGroupProps<T extends string = string> =
|
|
177
|
+
| {
|
|
178
|
+
type: 'radio'
|
|
179
|
+
name: string
|
|
180
|
+
value: T | null
|
|
181
|
+
onChange: (next: T) => void
|
|
182
|
+
options: OptionTileGroupOption<T>[]
|
|
183
|
+
ariaLabel?: string
|
|
184
|
+
columns?: number
|
|
185
|
+
className?: string
|
|
186
|
+
}
|
|
187
|
+
| {
|
|
188
|
+
type: 'checkbox'
|
|
189
|
+
value: T[]
|
|
190
|
+
onChange: (next: T[]) => void
|
|
191
|
+
options: OptionTileGroupOption<T>[]
|
|
192
|
+
ariaLabel?: string
|
|
193
|
+
columns?: number
|
|
194
|
+
className?: string
|
|
195
|
+
/** Optional shared `name` attr on the underlying checkboxes (rarely needed). */
|
|
196
|
+
name?: string
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function OptionTileGroup<T extends string = string>(
|
|
200
|
+
props: OptionTileGroupProps<T>,
|
|
201
|
+
) {
|
|
202
|
+
const gridStyle = props.columns
|
|
203
|
+
? { gridTemplateColumns: `repeat(${props.columns}, minmax(0, 1fr))` }
|
|
204
|
+
: { gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))' }
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<fieldset
|
|
208
|
+
className={cn('grid gap-2', props.className)}
|
|
209
|
+
style={gridStyle}
|
|
210
|
+
>
|
|
211
|
+
{props.ariaLabel && <legend className="sr-only">{props.ariaLabel}</legend>}
|
|
212
|
+
{props.options.map((opt) => {
|
|
213
|
+
if (props.type === 'radio') {
|
|
214
|
+
return (
|
|
215
|
+
<OptionTile
|
|
216
|
+
key={opt.value}
|
|
217
|
+
selected={props.value === opt.value}
|
|
218
|
+
onSelect={() => props.onChange(opt.value)}
|
|
219
|
+
inputType="radio"
|
|
220
|
+
inputName={props.name}
|
|
221
|
+
label={opt.label}
|
|
222
|
+
description={opt.description}
|
|
223
|
+
disabled={opt.disabled}
|
|
224
|
+
className={undefined}
|
|
225
|
+
/>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
// checkbox variant
|
|
229
|
+
const isSelected = props.value.includes(opt.value)
|
|
230
|
+
return (
|
|
231
|
+
<OptionTile
|
|
232
|
+
key={opt.value}
|
|
233
|
+
selected={isSelected}
|
|
234
|
+
onSelect={() =>
|
|
235
|
+
props.onChange(
|
|
236
|
+
isSelected
|
|
237
|
+
? props.value.filter((v) => v !== opt.value)
|
|
238
|
+
: [...props.value, opt.value],
|
|
239
|
+
)
|
|
240
|
+
}
|
|
241
|
+
inputType="checkbox"
|
|
242
|
+
inputName={props.name}
|
|
243
|
+
label={opt.label}
|
|
244
|
+
description={opt.description}
|
|
245
|
+
disabled={opt.disabled}
|
|
246
|
+
className={undefined}
|
|
247
|
+
/>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
</fieldset>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PageHero — large title row that sits IN the content area, beneath the
|
|
3
|
+
* sticky header. At rest it owns the visual weight; on scroll the
|
|
4
|
+
* AppHeader's breadcrumbs become the persistent label.
|
|
5
|
+
*
|
|
6
|
+
* Promoted from `brand-ai-extension/webapp/src/components/ui/page-hero.tsx`
|
|
7
|
+
* as part of Phase A of the v1.1 lens port batch — used by Swipes,
|
|
8
|
+
* Collections, Discover; shared lens-local equivalents existed in earlier
|
|
9
|
+
* ports.
|
|
10
|
+
*
|
|
11
|
+
* Token-clean: only public tokens (`--accent`, `--foreground`,
|
|
12
|
+
* `--text-tertiary`).
|
|
13
|
+
*/
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
import { cn } from './lib/utils'
|
|
16
|
+
|
|
17
|
+
export interface PageHeroProps {
|
|
18
|
+
/** Small uppercase label above the title, usually colored with --accent. */
|
|
19
|
+
eyebrow?: string
|
|
20
|
+
title: string
|
|
21
|
+
subtitle?: string
|
|
22
|
+
/** Right-aligned trailing content — typically a row of action buttons. */
|
|
23
|
+
actions?: ReactNode
|
|
24
|
+
/**
|
|
25
|
+
* Optional left-side adornment rendered alongside the title — e.g. the
|
|
26
|
+
* brand profile's logo block. Sits in the title row (not next to the
|
|
27
|
+
* eyebrow), vertically aligned with the title text. The hero gracefully
|
|
28
|
+
* collapses if this is `undefined` so pages without an adornment look
|
|
29
|
+
* unchanged.
|
|
30
|
+
*/
|
|
31
|
+
leading?: ReactNode
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function PageHero({
|
|
36
|
+
eyebrow,
|
|
37
|
+
title,
|
|
38
|
+
subtitle,
|
|
39
|
+
actions,
|
|
40
|
+
leading,
|
|
41
|
+
className,
|
|
42
|
+
}: PageHeroProps) {
|
|
43
|
+
return (
|
|
44
|
+
<div className={cn('flex items-end justify-between gap-4 flex-wrap pt-4 pb-5', className)}>
|
|
45
|
+
<div className="min-w-0 flex-1 flex items-center gap-5">
|
|
46
|
+
{leading && <div className="shrink-0">{leading}</div>}
|
|
47
|
+
<div className="min-w-0 flex-1">
|
|
48
|
+
{eyebrow && (
|
|
49
|
+
<div
|
|
50
|
+
className="text-[11px] font-bold uppercase tracking-[0.1em] mb-1.5"
|
|
51
|
+
style={{ color: 'rgb(var(--accent))' }}
|
|
52
|
+
>
|
|
53
|
+
{eyebrow}
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
<h1
|
|
57
|
+
className="text-[24px] font-bold tracking-[-0.02em] leading-tight"
|
|
58
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
59
|
+
>
|
|
60
|
+
{title}
|
|
61
|
+
</h1>
|
|
62
|
+
{subtitle && (
|
|
63
|
+
<p
|
|
64
|
+
className="text-[13px] mt-1.5 leading-[1.5]"
|
|
65
|
+
style={{
|
|
66
|
+
color: 'rgb(var(--text-tertiary))',
|
|
67
|
+
maxWidth: 'var(--prose-narrow)',
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{subtitle}
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
|
|
76
|
+
</div>
|
|
77
|
+
)
|
|
78
|
+
}
|