@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.
Files changed (102) hide show
  1. package/README.md +28 -0
  2. package/package.json +53 -0
  3. package/src/accordion.tsx +153 -0
  4. package/src/atoms.tsx +197 -0
  5. package/src/badge.tsx +91 -0
  6. package/src/brand-logo-tile.tsx +200 -0
  7. package/src/bulk-action-bar.tsx +104 -0
  8. package/src/button-group.tsx +46 -0
  9. package/src/button.tsx +93 -0
  10. package/src/calculator-shell-v2.tsx +380 -0
  11. package/src/calculator-shell.tsx +310 -0
  12. package/src/card.tsx +369 -0
  13. package/src/checkbox.tsx +114 -0
  14. package/src/chip-nav.tsx +347 -0
  15. package/src/choropleth.tsx +62 -0
  16. package/src/code-block.tsx +235 -0
  17. package/src/completion-ring.tsx +119 -0
  18. package/src/copy-field.tsx +169 -0
  19. package/src/data-grid/column-options-menu.tsx +127 -0
  20. package/src/data-grid/data-grid.tsx +391 -0
  21. package/src/data-grid/drawer-panel.tsx +146 -0
  22. package/src/data-grid/index.ts +29 -0
  23. package/src/data-grid/sortable-parts.tsx +215 -0
  24. package/src/data-grid/table-body.tsx +238 -0
  25. package/src/data-grid/table-elements.tsx +135 -0
  26. package/src/data-grid/table-header.tsx +95 -0
  27. package/src/data-grid/table-overlays.tsx +78 -0
  28. package/src/data-grid/table-parts.tsx +15 -0
  29. package/src/data-grid/table-summary-footer.tsx +101 -0
  30. package/src/data-grid/table-view.tsx +176 -0
  31. package/src/data-grid/types.ts +141 -0
  32. package/src/data-grid/use-grid-columns.ts +311 -0
  33. package/src/data-grid/use-grid-editing.ts +154 -0
  34. package/src/device-frame.tsx +274 -0
  35. package/src/dropdown-button.tsx +221 -0
  36. package/src/dropdown-menu.tsx +450 -0
  37. package/src/empty-state.tsx +85 -0
  38. package/src/file-upload/file-drop-zone.tsx +156 -0
  39. package/src/file-upload/file-preview-grid.tsx +39 -0
  40. package/src/file-upload/file-preview-item.tsx +175 -0
  41. package/src/file-upload/file-thumbnail.tsx +98 -0
  42. package/src/file-upload/file-upload-button.tsx +95 -0
  43. package/src/file-upload/index.ts +13 -0
  44. package/src/file-upload/use-file-upload.ts +371 -0
  45. package/src/filter-bar.tsx +125 -0
  46. package/src/filter-dropdown.tsx +289 -0
  47. package/src/floating-drawer.tsx +420 -0
  48. package/src/folder-card.tsx +188 -0
  49. package/src/folder-tree-picker.tsx +511 -0
  50. package/src/format.ts +117 -0
  51. package/src/frame-stack.tsx +73 -0
  52. package/src/hero-form-card.tsx +471 -0
  53. package/src/index.ts +372 -0
  54. package/src/input.tsx +130 -0
  55. package/src/kbd.tsx +41 -0
  56. package/src/lib/layout.ts +22 -0
  57. package/src/lib/utils.ts +6 -0
  58. package/src/markdown.tsx +214 -0
  59. package/src/menu-button.tsx +142 -0
  60. package/src/meta-previews/CreativeMedia.tsx +119 -0
  61. package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
  62. package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
  63. package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
  64. package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
  65. package/src/meta-previews/MetaFeedPreview.tsx +294 -0
  66. package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
  67. package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
  68. package/src/meta-previews/MetaReelPreview.tsx +750 -0
  69. package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
  70. package/src/meta-previews/MetaSearchPreview.tsx +113 -0
  71. package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
  72. package/src/meta-previews/ProfileAvatar.tsx +79 -0
  73. package/src/meta-previews/defaults.ts +27 -0
  74. package/src/meta-previews/index.ts +37 -0
  75. package/src/meta-previews/types.ts +219 -0
  76. package/src/metric-card.tsx +117 -0
  77. package/src/modal.tsx +131 -0
  78. package/src/option-tile.tsx +252 -0
  79. package/src/page-hero.tsx +78 -0
  80. package/src/page-shell.tsx +440 -0
  81. package/src/pagination.tsx +86 -0
  82. package/src/pill-button.tsx +103 -0
  83. package/src/profile-section.tsx +389 -0
  84. package/src/progress-bar.tsx +84 -0
  85. package/src/radio.tsx +172 -0
  86. package/src/range-slider.tsx +161 -0
  87. package/src/resizable.tsx +282 -0
  88. package/src/search-bar.tsx +81 -0
  89. package/src/segmented-pill.tsx +143 -0
  90. package/src/shell.tsx +464 -0
  91. package/src/slider.tsx +107 -0
  92. package/src/sortable-table.tsx +198 -0
  93. package/src/sortable.tsx +137 -0
  94. package/src/stat-row.tsx +132 -0
  95. package/src/stats-grid.tsx +38 -0
  96. package/src/step-loader.tsx +141 -0
  97. package/src/styles.css +562 -0
  98. package/src/tabs.tsx +143 -0
  99. package/src/tag-chip-input.tsx +394 -0
  100. package/src/textarea.tsx +39 -0
  101. package/src/toast.tsx +59 -0
  102. package/src/tokens.css +579 -0
@@ -0,0 +1,394 @@
1
+ /**
2
+ * TagChipInput — type-to-create chips with autocomplete.
3
+ *
4
+ * Behavior:
5
+ * • Comma or Enter commits the current draft into a chip.
6
+ * • Backspace on an empty input removes the last chip.
7
+ * • Autocomplete suggestions surface as a popover; filtered by the
8
+ * current draft (case-insensitive). Click or arrow-keys + Enter to
9
+ * pick a suggestion.
10
+ * • Tags are case-insensitively deduped; the displayed label preserves
11
+ * the user's first casing per session.
12
+ * • Submitting a new label (not in suggestions) creates it inline.
13
+ *
14
+ * Token discipline:
15
+ * • Chips inherit Badge tones via tokens.
16
+ * • Popover uses --popover / --popover-foreground / --shadow-lg.
17
+ * • No hex, no inline SVG outside Lucide.
18
+ *
19
+ * First consumer: Keywords lens (NewSetPage, NewGroupPage). Generic —
20
+ * promote anywhere a multi-tag input is needed.
21
+ */
22
+
23
+ import {
24
+ useCallback,
25
+ useEffect,
26
+ useId,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ type ChangeEvent,
31
+ type KeyboardEvent,
32
+ } from 'react'
33
+ import { createPortal } from 'react-dom'
34
+ import { Plus, X } from 'lucide-react'
35
+ import { cn } from './lib/utils'
36
+
37
+ export interface TagChipInputProps {
38
+ /** Current list of tag labels. */
39
+ value: string[]
40
+ onChange: (next: string[]) => void
41
+ /** Existing labels (across all items in scope) used for autocomplete. */
42
+ suggestions?: string[]
43
+ placeholder?: string
44
+ /** Hard cap on the number of chips. When reached, the input becomes
45
+ * disabled with a "max reached" hint. */
46
+ max?: number
47
+ /** Maximum length of any single tag. Defaults to 60. */
48
+ maxLength?: number
49
+ /** Disable input + interactions. */
50
+ disabled?: boolean
51
+ /** id attached to the text input — for label-for binding. */
52
+ id?: string
53
+ /** Apply error styling. */
54
+ error?: boolean
55
+ className?: string
56
+ }
57
+
58
+ function normalize(label: string): string {
59
+ return label.trim().toLowerCase()
60
+ }
61
+
62
+ export function TagChipInput({
63
+ value,
64
+ onChange,
65
+ suggestions = [],
66
+ placeholder = 'Add a tag…',
67
+ max,
68
+ maxLength = 60,
69
+ disabled,
70
+ id,
71
+ error,
72
+ className,
73
+ }: TagChipInputProps) {
74
+ const [draft, setDraft] = useState('')
75
+ const [open, setOpen] = useState(false)
76
+ const [activeIndex, setActiveIndex] = useState(0)
77
+ const inputRef = useRef<HTMLInputElement | null>(null)
78
+ const wrapperRef = useRef<HTMLDivElement | null>(null)
79
+ const popoverRef = useRef<HTMLDivElement | null>(null)
80
+ const generatedId = useId()
81
+ const popoverId = `tag-chip-${generatedId.replace(/:/g, '')}`
82
+
83
+ const atMax = typeof max === 'number' && value.length >= max
84
+ const isDisabled = disabled || atMax
85
+
86
+ // Filter suggestions: not already in value, matches draft (substring,
87
+ // case-insensitive), sorted alpha. Limited to 8 to keep the popover
88
+ // tight.
89
+ const filteredSuggestions = useMemo(() => {
90
+ const selected = new Set(value.map(normalize))
91
+ const needle = normalize(draft)
92
+ return suggestions
93
+ .filter((s) => {
94
+ const n = normalize(s)
95
+ if (selected.has(n)) return false
96
+ if (needle && !n.includes(needle)) return false
97
+ return true
98
+ })
99
+ .slice(0, 8)
100
+ }, [suggestions, value, draft])
101
+
102
+ // Show the "create" affordance when the draft is non-empty and isn't
103
+ // already a chip / suggestion (case-insensitive).
104
+ const canCreate = useMemo(() => {
105
+ const needle = normalize(draft)
106
+ if (!needle) return false
107
+ if (value.some((v) => normalize(v) === needle)) return false
108
+ if (suggestions.some((s) => normalize(s) === needle)) return false
109
+ return true
110
+ }, [draft, value, suggestions])
111
+
112
+ const optionCount = filteredSuggestions.length + (canCreate ? 1 : 0)
113
+
114
+ // Clamp activeIndex when the option list changes.
115
+ useEffect(() => {
116
+ if (activeIndex >= optionCount) {
117
+ setActiveIndex(Math.max(0, optionCount - 1))
118
+ }
119
+ }, [optionCount, activeIndex])
120
+
121
+ const addTag = useCallback(
122
+ (label: string) => {
123
+ const cleaned = label.trim()
124
+ if (!cleaned) return
125
+ const exists = value.some((v) => normalize(v) === normalize(cleaned))
126
+ if (exists) {
127
+ setDraft('')
128
+ return
129
+ }
130
+ if (typeof max === 'number' && value.length >= max) return
131
+ onChange([...value, cleaned])
132
+ setDraft('')
133
+ setActiveIndex(0)
134
+ },
135
+ [value, onChange, max],
136
+ )
137
+
138
+ const removeAt = useCallback(
139
+ (index: number) => {
140
+ const next = value.filter((_, i) => i !== index)
141
+ onChange(next)
142
+ },
143
+ [value, onChange],
144
+ )
145
+
146
+ const commitFromKeyboard = useCallback(() => {
147
+ if (!open || optionCount === 0) {
148
+ // Plain submit of whatever's in the draft.
149
+ if (draft.trim()) addTag(draft)
150
+ return
151
+ }
152
+ if (activeIndex < filteredSuggestions.length) {
153
+ addTag(filteredSuggestions[activeIndex]!)
154
+ } else {
155
+ addTag(draft)
156
+ }
157
+ }, [open, optionCount, activeIndex, filteredSuggestions, draft, addTag])
158
+
159
+ const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
160
+ setDraft(event.target.value)
161
+ setOpen(true)
162
+ setActiveIndex(0)
163
+ }
164
+
165
+ const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
166
+ if (event.key === 'Enter') {
167
+ event.preventDefault()
168
+ commitFromKeyboard()
169
+ return
170
+ }
171
+ if (event.key === ',') {
172
+ event.preventDefault()
173
+ if (draft.trim()) addTag(draft)
174
+ return
175
+ }
176
+ if (event.key === 'Backspace' && draft.length === 0 && value.length > 0) {
177
+ event.preventDefault()
178
+ removeAt(value.length - 1)
179
+ return
180
+ }
181
+ if (event.key === 'ArrowDown' && optionCount > 0) {
182
+ event.preventDefault()
183
+ setOpen(true)
184
+ setActiveIndex((i) => (i + 1) % optionCount)
185
+ return
186
+ }
187
+ if (event.key === 'ArrowUp' && optionCount > 0) {
188
+ event.preventDefault()
189
+ setOpen(true)
190
+ setActiveIndex((i) => (i - 1 + optionCount) % optionCount)
191
+ return
192
+ }
193
+ if (event.key === 'Escape') {
194
+ setOpen(false)
195
+ return
196
+ }
197
+ }
198
+
199
+ // Outside-click + Esc dismissal of the popover.
200
+ useEffect(() => {
201
+ if (!open) return
202
+ const onPointerDown = (event: PointerEvent) => {
203
+ const target = event.target as Node | null
204
+ if (!target) return
205
+ if (wrapperRef.current?.contains(target)) return
206
+ if (popoverRef.current?.contains(target)) return
207
+ setOpen(false)
208
+ }
209
+ document.addEventListener('pointerdown', onPointerDown)
210
+ return () => document.removeEventListener('pointerdown', onPointerDown)
211
+ }, [open])
212
+
213
+ // Position the popover under the wrapper.
214
+ const [popoverPos, setPopoverPos] = useState<{
215
+ left: number
216
+ top: number
217
+ width: number
218
+ } | null>(null)
219
+ useEffect(() => {
220
+ if (!open) {
221
+ setPopoverPos(null)
222
+ return
223
+ }
224
+ const measure = () => {
225
+ const wrap = wrapperRef.current
226
+ if (!wrap) return
227
+ const rect = wrap.getBoundingClientRect()
228
+ setPopoverPos({
229
+ left: rect.left,
230
+ top: rect.bottom + 4,
231
+ width: rect.width,
232
+ })
233
+ }
234
+ measure()
235
+ window.addEventListener('resize', measure)
236
+ window.addEventListener('scroll', measure, true)
237
+ return () => {
238
+ window.removeEventListener('resize', measure)
239
+ window.removeEventListener('scroll', measure, true)
240
+ }
241
+ }, [open])
242
+
243
+ return (
244
+ <>
245
+ <div
246
+ ref={wrapperRef}
247
+ className={cn(
248
+ 'flex flex-wrap items-center gap-1.5 px-2 py-1.5 min-h-10 cursor-text',
249
+ 'border transition-colors',
250
+ error
251
+ ? 'border-[rgb(var(--ring-error))]'
252
+ : 'border-[rgb(var(--border))] focus-within:border-[rgb(var(--accent))]',
253
+ isDisabled && 'opacity-60 cursor-not-allowed',
254
+ className,
255
+ )}
256
+ style={{
257
+ background: 'rgb(var(--bg-input))',
258
+ borderRadius: 'var(--radius-sm)',
259
+ }}
260
+ onClick={() => {
261
+ if (!isDisabled) inputRef.current?.focus()
262
+ }}
263
+ >
264
+ {value.map((label, index) => (
265
+ <span
266
+ key={`${label}-${index}`}
267
+ className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 text-[12px] font-medium"
268
+ style={{
269
+ background: 'rgb(var(--surface-overlay))',
270
+ color: 'rgb(var(--foreground))',
271
+ borderRadius: 'var(--radius-xs)',
272
+ }}
273
+ >
274
+ <span className="truncate max-w-[12rem]">{label}</span>
275
+ <button
276
+ type="button"
277
+ aria-label={`Remove tag ${label}`}
278
+ onClick={(e) => {
279
+ e.stopPropagation()
280
+ removeAt(index)
281
+ }}
282
+ className="inline-flex h-4 w-4 items-center justify-center rounded-[var(--radius-xs)] transition-colors hover:bg-[rgb(var(--surface-overlay-strong))]"
283
+ style={{ color: 'rgb(var(--text-muted))' }}
284
+ >
285
+ <X className="h-3 w-3" aria-hidden="true" />
286
+ </button>
287
+ </span>
288
+ ))}
289
+ <input
290
+ ref={inputRef}
291
+ id={id}
292
+ type="text"
293
+ value={draft}
294
+ onChange={handleChange}
295
+ onKeyDown={handleKeyDown}
296
+ onFocus={() => setOpen(true)}
297
+ placeholder={value.length === 0 ? placeholder : ''}
298
+ disabled={isDisabled}
299
+ maxLength={maxLength}
300
+ autoComplete="off"
301
+ aria-autocomplete="list"
302
+ aria-controls={open ? popoverId : undefined}
303
+ aria-expanded={open}
304
+ className="flex-1 min-w-[6rem] bg-transparent text-[13px] outline-none disabled:cursor-not-allowed"
305
+ style={{ color: 'rgb(var(--foreground))' }}
306
+ />
307
+ </div>
308
+
309
+ {atMax && (
310
+ <p
311
+ className="text-[11px] mt-1"
312
+ style={{ color: 'rgb(var(--text-tertiary))' }}
313
+ >
314
+ Maximum of {max} tags reached.
315
+ </p>
316
+ )}
317
+
318
+ {open && popoverPos && optionCount > 0 && typeof document !== 'undefined'
319
+ ? createPortal(
320
+ <div
321
+ ref={popoverRef}
322
+ id={popoverId}
323
+ role="listbox"
324
+ className="fixed z-[100] p-1 overflow-y-auto max-h-64"
325
+ style={{
326
+ left: popoverPos.left,
327
+ top: popoverPos.top,
328
+ width: popoverPos.width,
329
+ background: 'rgb(var(--popover))',
330
+ color: 'rgb(var(--popover-foreground))',
331
+ border: '1px solid rgb(var(--border))',
332
+ borderRadius: 'var(--radius-md)',
333
+ boxShadow: 'var(--shadow-lg, 0 10px 32px rgba(0,0,0,0.18))',
334
+ }}
335
+ >
336
+ {filteredSuggestions.map((label, index) => {
337
+ const isActive = index === activeIndex
338
+ return (
339
+ <button
340
+ key={label}
341
+ type="button"
342
+ role="option"
343
+ aria-selected={isActive}
344
+ onClick={() => addTag(label)}
345
+ onMouseEnter={() => setActiveIndex(index)}
346
+ className={cn(
347
+ 'w-full flex items-center gap-2 px-2 py-1.5 text-left text-[13px]',
348
+ 'rounded-[var(--radius-xs)] cursor-default',
349
+ isActive
350
+ ? 'bg-[rgb(var(--surface-overlay))]'
351
+ : 'hover:bg-[rgb(var(--surface-overlay-soft))]',
352
+ )}
353
+ style={{ color: 'rgb(var(--foreground))' }}
354
+ >
355
+ <span className="truncate">{label}</span>
356
+ </button>
357
+ )
358
+ })}
359
+ {canCreate && (
360
+ <button
361
+ key="__create__"
362
+ type="button"
363
+ role="option"
364
+ aria-selected={activeIndex === filteredSuggestions.length}
365
+ onClick={() => addTag(draft)}
366
+ onMouseEnter={() =>
367
+ setActiveIndex(filteredSuggestions.length)
368
+ }
369
+ className={cn(
370
+ 'w-full flex items-center gap-2 px-2 py-1.5 text-left text-[13px]',
371
+ 'rounded-[var(--radius-xs)] cursor-default',
372
+ activeIndex === filteredSuggestions.length
373
+ ? 'bg-[rgb(var(--surface-overlay))]'
374
+ : 'hover:bg-[rgb(var(--surface-overlay-soft))]',
375
+ )}
376
+ style={{ color: 'rgb(var(--foreground))' }}
377
+ >
378
+ <Plus
379
+ className="h-3.5 w-3.5 shrink-0"
380
+ style={{ color: 'rgb(var(--text-muted))' }}
381
+ aria-hidden="true"
382
+ />
383
+ <span className="truncate">
384
+ Create &ldquo;{draft.trim()}&rdquo;
385
+ </span>
386
+ </button>
387
+ )}
388
+ </div>,
389
+ document.body,
390
+ )
391
+ : null}
392
+ </>
393
+ )
394
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Textarea — typed primitive in the .input-shell.textarea-shell variant.
3
+ * Auto-grows to content (resize: vertical), shares the same focus/error/
4
+ * disabled visuals as Input.
5
+ *
6
+ * <Textarea
7
+ * value={v}
8
+ * onChange={e => setV(e.target.value)}
9
+ * placeholder="Write something…"
10
+ * rows={4}
11
+ * />
12
+ */
13
+ import { forwardRef, type TextareaHTMLAttributes } from 'react'
14
+ import { cn } from './lib/utils'
15
+
16
+ export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
17
+ error?: boolean
18
+ /** Override classes on the .input-shell wrapper. */
19
+ shellClassName?: string
20
+ }
21
+
22
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
23
+ { error, shellClassName, className, disabled, rows = 4, ...rest },
24
+ ref,
25
+ ) {
26
+ return (
27
+ <div
28
+ className={cn(
29
+ 'input-shell',
30
+ 'textarea-shell',
31
+ error && 'is-error',
32
+ disabled && 'is-disabled',
33
+ shellClassName,
34
+ )}
35
+ >
36
+ <textarea ref={ref} disabled={disabled} rows={rows} className={className} {...rest} />
37
+ </div>
38
+ )
39
+ })
package/src/toast.tsx ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @lovett/ui — toast surface.
3
+ *
4
+ * Sonner is the toast provider; this module is a thin wrapper that
5
+ * (a) re-exports Sonner's `toast` API verbatim, and
6
+ * (b) exports a `<Toaster />` component pre-styled with @lovett/ui
7
+ * tokens (--toast-bg, --toast-text, --toast-border).
8
+ *
9
+ * Per ADR-008 Decision 5, `@lovett/ui` does not own the toast
10
+ * primitive itself — it owns the wrapper. Apps mount one `<Toaster />`
11
+ * at the root and call `toast(...)` from anywhere.
12
+ *
13
+ * import { Toaster, toast } from '@lovett/ui'
14
+ *
15
+ * <Toaster /> // mount once at app root
16
+ * toast.success('Saved') // call from anywhere
17
+ * toast.error('Something went wrong')
18
+ *
19
+ * Sonner's full API is available via the re-exported `toast`.
20
+ */
21
+
22
+ import { Toaster as SonnerToaster, toast } from 'sonner'
23
+ import type { ComponentProps } from 'react'
24
+
25
+ type SonnerToasterProps = ComponentProps<typeof SonnerToaster>
26
+
27
+ /**
28
+ * Token-aware `<Toaster />` shell.
29
+ *
30
+ * Defaults: top-right, 12px gap, 5s duration. The toastOptions wire
31
+ * Sonner's internal CSS variables to our `--toast-*` tokens so toasts
32
+ * adopt the design system surface color, border, and text color.
33
+ *
34
+ * Apps can override any prop (position, duration, theme, etc.) by
35
+ * passing it through.
36
+ */
37
+ export function Toaster(props: SonnerToasterProps) {
38
+ return (
39
+ <SonnerToaster
40
+ position="top-right"
41
+ gap={12}
42
+ duration={5000}
43
+ toastOptions={{
44
+ style: {
45
+ background: 'rgb(var(--toast-bg))',
46
+ color: 'rgb(var(--toast-text))',
47
+ border: '1px solid rgb(var(--toast-border))',
48
+ borderRadius: 'var(--radius-md)',
49
+ boxShadow: 'var(--shadow-lg)',
50
+ fontFamily: 'var(--font-sans)',
51
+ fontSize: '13px',
52
+ },
53
+ }}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ export { toast }