@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,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 “{draft.trim()}”
|
|
385
|
+
</span>
|
|
386
|
+
</button>
|
|
387
|
+
)}
|
|
388
|
+
</div>,
|
|
389
|
+
document.body,
|
|
390
|
+
)
|
|
391
|
+
: null}
|
|
392
|
+
</>
|
|
393
|
+
)
|
|
394
|
+
}
|
package/src/textarea.tsx
ADDED
|
@@ -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 }
|