@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,371 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useFileUpload — stateful file-selection, validation, text-extraction, and
|
|
3
|
+
* XHR upload hook with per-file progress tracking.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from brand-ai-extension (ADR-036 D20).
|
|
6
|
+
*
|
|
7
|
+
* D14 fix: the original hook read `import.meta.env.VITE_API_URL` and
|
|
8
|
+
* hardcoded a fallback URL. A @lovett/ui primitive must not read env vars or
|
|
9
|
+
* embed URLs. Consumers pass `uploadUrl` (required) and optional `authHeaders`
|
|
10
|
+
* so the workspace `lib/api.ts` config can supply both without the primitive
|
|
11
|
+
* knowing anything about the deployment environment.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useState, useCallback, useRef, useMemo } from 'react'
|
|
15
|
+
import { toast } from 'sonner'
|
|
16
|
+
|
|
17
|
+
// Configuration defaults
|
|
18
|
+
const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20 MB
|
|
19
|
+
const MAX_FILES = 10
|
|
20
|
+
|
|
21
|
+
const ALLOWED_TYPES: Record<string, { category: string; ext: string }> = {
|
|
22
|
+
'image/jpeg': { category: 'image', ext: 'jpg' },
|
|
23
|
+
'image/png': { category: 'image', ext: 'png' },
|
|
24
|
+
'image/gif': { category: 'image', ext: 'gif' },
|
|
25
|
+
'application/pdf': { category: 'pdf', ext: 'pdf' },
|
|
26
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': {
|
|
27
|
+
category: 'document',
|
|
28
|
+
ext: 'docx',
|
|
29
|
+
},
|
|
30
|
+
'text/plain': { category: 'text', ext: 'txt' },
|
|
31
|
+
'text/csv': { category: 'text', ext: 'csv' },
|
|
32
|
+
'text/html': { category: 'text', ext: 'html' },
|
|
33
|
+
'text/css': { category: 'text', ext: 'css' },
|
|
34
|
+
'text/xml': { category: 'text', ext: 'xml' },
|
|
35
|
+
'application/xml': { category: 'text', ext: 'xml' },
|
|
36
|
+
'application/javascript': { category: 'text', ext: 'js' },
|
|
37
|
+
'text/javascript': { category: 'text', ext: 'js' },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface UploadResult {
|
|
41
|
+
id: string
|
|
42
|
+
filename: string
|
|
43
|
+
fileType: string
|
|
44
|
+
fileSize: number
|
|
45
|
+
fileCategory: string
|
|
46
|
+
publicUrl?: string
|
|
47
|
+
status: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface PendingFile {
|
|
51
|
+
id: string
|
|
52
|
+
file: File
|
|
53
|
+
progress: number
|
|
54
|
+
status: 'pending' | 'uploading' | 'uploaded' | 'error'
|
|
55
|
+
error?: string
|
|
56
|
+
previewUrl?: string
|
|
57
|
+
extractedText?: string
|
|
58
|
+
uploadResult?: UploadResult
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UseFileUploadOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Absolute URL that accepts POST multipart/form-data uploads.
|
|
64
|
+
* Supplied by the consuming lens via `lib/api.ts` — never hardcoded here.
|
|
65
|
+
*/
|
|
66
|
+
uploadUrl: string
|
|
67
|
+
/**
|
|
68
|
+
* Optional HTTP headers forwarded with every XHR upload request
|
|
69
|
+
* (e.g. `{ Authorization: 'Bearer <token>' }`). Supplied by the
|
|
70
|
+
* consuming lens; the primitive never reads env vars or auth stores.
|
|
71
|
+
*/
|
|
72
|
+
authHeaders?: Record<string, string>
|
|
73
|
+
/** Conversation ID to associate uploads with an existing conversation. */
|
|
74
|
+
conversationId?: string | null | undefined
|
|
75
|
+
/** Maximum number of files (default: 10). */
|
|
76
|
+
maxFiles?: number
|
|
77
|
+
/** Maximum bytes per file (default: 20 MB). */
|
|
78
|
+
maxFileSize?: number
|
|
79
|
+
/** Called when a file fails to upload. */
|
|
80
|
+
onError?: (error: Error) => void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useFileUpload({
|
|
84
|
+
uploadUrl,
|
|
85
|
+
authHeaders,
|
|
86
|
+
conversationId,
|
|
87
|
+
maxFiles = MAX_FILES,
|
|
88
|
+
maxFileSize = MAX_FILE_SIZE,
|
|
89
|
+
onError,
|
|
90
|
+
}: UseFileUploadOptions) {
|
|
91
|
+
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([])
|
|
92
|
+
const [isUploading, setIsUploading] = useState(false)
|
|
93
|
+
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
|
94
|
+
|
|
95
|
+
// Stable session ID for this upload session
|
|
96
|
+
const sessionId = useMemo(
|
|
97
|
+
() => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
98
|
+
[],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const generateId = () =>
|
|
102
|
+
`file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
103
|
+
|
|
104
|
+
const getFileInfo = (mimeType: string) => ALLOWED_TYPES[mimeType] ?? null
|
|
105
|
+
|
|
106
|
+
const validateFile = (file: File): string | null => {
|
|
107
|
+
if (file.size > maxFileSize) {
|
|
108
|
+
return `${file.name} exceeds ${maxFileSize / 1024 / 1024}MB limit`
|
|
109
|
+
}
|
|
110
|
+
if (!getFileInfo(file.type)) {
|
|
111
|
+
return `${file.name} has unsupported type (${file.type || 'unknown'})`
|
|
112
|
+
}
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Extract text from DOCX files — dynamic import keeps mammoth out of the
|
|
117
|
+
// initial bundle when the feature is unused.
|
|
118
|
+
const extractDocxText = async (file: File): Promise<string | undefined> => {
|
|
119
|
+
try {
|
|
120
|
+
const mammoth = await import('mammoth')
|
|
121
|
+
const arrayBuffer = await file.arrayBuffer()
|
|
122
|
+
const result = await mammoth.extractRawText({ arrayBuffer })
|
|
123
|
+
return result.value
|
|
124
|
+
} catch (error) {
|
|
125
|
+
console.error('Failed to extract DOCX text:', error)
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const readTextFile = async (file: File): Promise<string | undefined> => {
|
|
131
|
+
try {
|
|
132
|
+
return await file.text()
|
|
133
|
+
} catch (error) {
|
|
134
|
+
console.error('Failed to read text file:', error)
|
|
135
|
+
return undefined
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const processFile = async (file: File): Promise<Partial<PendingFile>> => {
|
|
140
|
+
const fileInfo = getFileInfo(file.type)
|
|
141
|
+
if (!fileInfo) return {}
|
|
142
|
+
|
|
143
|
+
if (fileInfo.category === 'image') {
|
|
144
|
+
return { previewUrl: URL.createObjectURL(file) }
|
|
145
|
+
}
|
|
146
|
+
if (fileInfo.category === 'document') {
|
|
147
|
+
const extractedText = await extractDocxText(file)
|
|
148
|
+
return extractedText !== undefined ? { extractedText } : {}
|
|
149
|
+
}
|
|
150
|
+
if (fileInfo.category === 'text') {
|
|
151
|
+
const extractedText = await readTextFile(file)
|
|
152
|
+
return extractedText !== undefined ? { extractedText } : {}
|
|
153
|
+
}
|
|
154
|
+
return {}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// XHR upload — kept over fetch so we get granular upload-progress events.
|
|
158
|
+
const uploadFile = async (pendingFile: PendingFile): Promise<UploadResult> => {
|
|
159
|
+
const formData = new FormData()
|
|
160
|
+
formData.append('file', pendingFile.file)
|
|
161
|
+
formData.append('sessionId', sessionId)
|
|
162
|
+
if (conversationId) {
|
|
163
|
+
formData.append('conversationId', conversationId)
|
|
164
|
+
}
|
|
165
|
+
formData.append('originalFilename', pendingFile.file.name)
|
|
166
|
+
if (pendingFile.extractedText) {
|
|
167
|
+
formData.append('extractedText', pendingFile.extractedText)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return new Promise((resolve, reject) => {
|
|
171
|
+
const xhr = new XMLHttpRequest()
|
|
172
|
+
const controller = new AbortController()
|
|
173
|
+
abortControllersRef.current.set(pendingFile.id, controller)
|
|
174
|
+
|
|
175
|
+
xhr.upload.addEventListener('progress', (e) => {
|
|
176
|
+
if (e.lengthComputable) {
|
|
177
|
+
const progress = Math.round((e.loaded / e.total) * 100)
|
|
178
|
+
setPendingFiles((prev) =>
|
|
179
|
+
prev.map((f) => (f.id === pendingFile.id ? { ...f, progress } : f)),
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
xhr.addEventListener('load', () => {
|
|
185
|
+
abortControllersRef.current.delete(pendingFile.id)
|
|
186
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
187
|
+
const result = JSON.parse(xhr.responseText) as UploadResult
|
|
188
|
+
resolve(result)
|
|
189
|
+
} else {
|
|
190
|
+
try {
|
|
191
|
+
const errBody = JSON.parse(xhr.responseText) as { error?: string }
|
|
192
|
+
reject(new Error(errBody.error ?? `Upload failed: ${xhr.status}`))
|
|
193
|
+
} catch {
|
|
194
|
+
reject(new Error(`Upload failed: ${xhr.status}`))
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
xhr.addEventListener('error', () => {
|
|
200
|
+
abortControllersRef.current.delete(pendingFile.id)
|
|
201
|
+
reject(new Error('Upload failed'))
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
xhr.addEventListener('abort', () => {
|
|
205
|
+
abortControllersRef.current.delete(pendingFile.id)
|
|
206
|
+
reject(new Error('Upload aborted'))
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
xhr.open('POST', uploadUrl)
|
|
210
|
+
|
|
211
|
+
// Forward caller-supplied auth headers (e.g. Bearer token)
|
|
212
|
+
if (authHeaders) {
|
|
213
|
+
for (const [key, value] of Object.entries(authHeaders)) {
|
|
214
|
+
xhr.setRequestHeader(key, value)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
xhr.send(formData)
|
|
219
|
+
})
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const uploadSingleFile = useCallback(
|
|
223
|
+
async (pendingFile: PendingFile) => {
|
|
224
|
+
setPendingFiles((prev) =>
|
|
225
|
+
prev.map((f) =>
|
|
226
|
+
f.id === pendingFile.id ? { ...f, status: 'uploading' as const } : f,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
const result = await uploadFile(pendingFile)
|
|
232
|
+
setPendingFiles((prev) =>
|
|
233
|
+
prev.map((f) =>
|
|
234
|
+
f.id === pendingFile.id
|
|
235
|
+
? { ...f, status: 'uploaded' as const, progress: 100, uploadResult: result }
|
|
236
|
+
: f,
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
return result
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const errorMessage =
|
|
242
|
+
error instanceof Error ? error.message : 'Upload failed'
|
|
243
|
+
setPendingFiles((prev) =>
|
|
244
|
+
prev.map((f) =>
|
|
245
|
+
f.id === pendingFile.id
|
|
246
|
+
? { ...f, status: 'error' as const, error: errorMessage }
|
|
247
|
+
: f,
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
onError?.(error instanceof Error ? error : new Error(errorMessage))
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
[uploadUrl, authHeaders, sessionId, conversationId, onError],
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
const addFiles = useCallback(
|
|
258
|
+
async (files: File[]) => {
|
|
259
|
+
const currentCount = pendingFiles.length
|
|
260
|
+
const availableSlots = maxFiles - currentCount
|
|
261
|
+
|
|
262
|
+
if (availableSlots <= 0) {
|
|
263
|
+
toast.error(`Maximum ${maxFiles} files allowed`)
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const filesToAdd = files.slice(0, availableSlots)
|
|
268
|
+
const errors: string[] = []
|
|
269
|
+
const validFiles: { file: File; processed: Partial<PendingFile> }[] = []
|
|
270
|
+
|
|
271
|
+
for (const file of filesToAdd) {
|
|
272
|
+
const error = validateFile(file)
|
|
273
|
+
if (error) {
|
|
274
|
+
errors.push(error)
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
const processed = await processFile(file)
|
|
278
|
+
validFiles.push({ file, processed })
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (errors.length > 0) {
|
|
282
|
+
errors.forEach((err) => toast.error(err))
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (validFiles.length > 0) {
|
|
286
|
+
const newPendingFiles: PendingFile[] = validFiles.map(
|
|
287
|
+
({ file, processed }) => ({
|
|
288
|
+
id: generateId(),
|
|
289
|
+
file,
|
|
290
|
+
progress: 0,
|
|
291
|
+
status: 'pending' as const,
|
|
292
|
+
...processed,
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
setPendingFiles((prev) => [...prev, ...newPendingFiles])
|
|
297
|
+
|
|
298
|
+
setIsUploading(true)
|
|
299
|
+
void Promise.all(newPendingFiles.map((pf) => uploadSingleFile(pf))).finally(
|
|
300
|
+
() => {
|
|
301
|
+
setIsUploading(false)
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (files.length > availableSlots) {
|
|
307
|
+
toast.warning(`Only ${availableSlots} files added (max ${maxFiles})`)
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
[pendingFiles.length, maxFiles, maxFileSize, uploadSingleFile],
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
const removeFile = useCallback((fileId: string) => {
|
|
314
|
+
setPendingFiles((prev) => {
|
|
315
|
+
const file = prev.find((f) => f.id === fileId)
|
|
316
|
+
if (file?.previewUrl) {
|
|
317
|
+
URL.revokeObjectURL(file.previewUrl)
|
|
318
|
+
}
|
|
319
|
+
const controller = abortControllersRef.current.get(fileId)
|
|
320
|
+
if (controller) {
|
|
321
|
+
controller.abort()
|
|
322
|
+
abortControllersRef.current.delete(fileId)
|
|
323
|
+
}
|
|
324
|
+
return prev.filter((f) => f.id !== fileId)
|
|
325
|
+
})
|
|
326
|
+
}, [])
|
|
327
|
+
|
|
328
|
+
const clearFiles = useCallback(() => {
|
|
329
|
+
pendingFiles.forEach((file) => {
|
|
330
|
+
if (file.previewUrl) URL.revokeObjectURL(file.previewUrl)
|
|
331
|
+
})
|
|
332
|
+
abortControllersRef.current.forEach((controller) => controller.abort())
|
|
333
|
+
abortControllersRef.current.clear()
|
|
334
|
+
setPendingFiles([])
|
|
335
|
+
}, [pendingFiles])
|
|
336
|
+
|
|
337
|
+
const getUploadedResults = useCallback(
|
|
338
|
+
(): UploadResult[] =>
|
|
339
|
+
pendingFiles
|
|
340
|
+
.filter((f) => f.status === 'uploaded' && f.uploadResult)
|
|
341
|
+
.map((f) => f.uploadResult!),
|
|
342
|
+
[pendingFiles],
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
const hasFilesUploading = pendingFiles.some(
|
|
346
|
+
(f) => f.status === 'uploading' || f.status === 'pending',
|
|
347
|
+
)
|
|
348
|
+
const hasUploadErrors = pendingFiles.some((f) => f.status === 'error')
|
|
349
|
+
const allFilesUploaded =
|
|
350
|
+
pendingFiles.length > 0 && pendingFiles.every((f) => f.status === 'uploaded')
|
|
351
|
+
const totalProgress =
|
|
352
|
+
pendingFiles.length > 0
|
|
353
|
+
? Math.round(
|
|
354
|
+
pendingFiles.reduce((sum, f) => sum + f.progress, 0) / pendingFiles.length,
|
|
355
|
+
)
|
|
356
|
+
: 0
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
pendingFiles,
|
|
360
|
+
addFiles,
|
|
361
|
+
removeFile,
|
|
362
|
+
clearFiles,
|
|
363
|
+
getUploadedResults,
|
|
364
|
+
isUploading,
|
|
365
|
+
hasFilesUploading,
|
|
366
|
+
hasUploadErrors,
|
|
367
|
+
allFilesUploaded,
|
|
368
|
+
totalProgress,
|
|
369
|
+
sessionId,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// FilterBar — canonical search + filter-chip toolbar (ADR-121).
|
|
2
|
+
//
|
|
3
|
+
// Modeled on the Assets filter bar (the owner's reference — "handles
|
|
4
|
+
// split dropdowns well"): a framed card holding a search field on the
|
|
5
|
+
// left, filter chips after it, a flexible spacer, then a right group
|
|
6
|
+
// (sort / view toggle / result count / actions). Lenses compose their
|
|
7
|
+
// own `<DropdownButton>` chips as children; this primitive owns the
|
|
8
|
+
// frame, the flex/wrap layout, the standardized search slot, and the
|
|
9
|
+
// left/right split — so every lens's bar looks and behaves the same.
|
|
10
|
+
//
|
|
11
|
+
// Compound API:
|
|
12
|
+
// <FilterBar variant="framed" aria-label="Asset filters">
|
|
13
|
+
// <FilterBar.Search value={q} onChange={setQ} aria-label="Search assets" />
|
|
14
|
+
// <DropdownButton … /> // filter chips (left group)
|
|
15
|
+
// <FilterBar.Spacer /> // pushes the rest right
|
|
16
|
+
// <DropdownButton … /> // sort (right group)
|
|
17
|
+
// <FilterBar.Count shown={n} total={m} />
|
|
18
|
+
// </FilterBar>
|
|
19
|
+
//
|
|
20
|
+
// Lives in @lovett/ui; the frame is the `.ds-card-surface` utility +
|
|
21
|
+
// `data-framed` (same as Card / ProfileSection). Token-boundary clean.
|
|
22
|
+
|
|
23
|
+
import type { ReactNode } from 'react'
|
|
24
|
+
import { Search } from 'lucide-react'
|
|
25
|
+
import { Input } from './input'
|
|
26
|
+
import { cn } from './lib/utils'
|
|
27
|
+
|
|
28
|
+
export interface FilterBarProps {
|
|
29
|
+
/**
|
|
30
|
+
* `framed` (default) = bordered `.ds-card-surface` card, matching the
|
|
31
|
+
* Assets bar. `bare` = no frame, just the flex row (for bars that sit
|
|
32
|
+
* inside an already-framed container).
|
|
33
|
+
*/
|
|
34
|
+
variant?: 'framed' | 'bare'
|
|
35
|
+
/** Accessible label for the toolbar group (e.g. "Asset filters"). */
|
|
36
|
+
'aria-label'?: string
|
|
37
|
+
className?: string
|
|
38
|
+
children: ReactNode
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function FilterBarRoot({
|
|
42
|
+
variant = 'framed',
|
|
43
|
+
'aria-label': ariaLabel,
|
|
44
|
+
className,
|
|
45
|
+
children,
|
|
46
|
+
}: FilterBarProps) {
|
|
47
|
+
const framed = variant === 'framed'
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
role="group"
|
|
51
|
+
aria-label={ariaLabel}
|
|
52
|
+
data-framed={framed ? 'true' : undefined}
|
|
53
|
+
className={cn(
|
|
54
|
+
'filter-bar flex flex-wrap items-center gap-2',
|
|
55
|
+
// 10px frame padding matches the <Shell> outer frame (the SEM Spec
|
|
56
|
+
// Overview bar) — the established thinner filter-bar padding. ADR-121.
|
|
57
|
+
framed && 'ds-card-surface p-2.5',
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{children}
|
|
62
|
+
</div>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FilterBarSearchProps {
|
|
67
|
+
value: string
|
|
68
|
+
onChange: (next: string) => void
|
|
69
|
+
placeholder?: string
|
|
70
|
+
/** Required — icon-only field needs an accessible name. */
|
|
71
|
+
'aria-label': string
|
|
72
|
+
/** Override the default width sizing if a bar needs it. */
|
|
73
|
+
className?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Standardized search field: small `Input` with a leading magnifier. */
|
|
77
|
+
function FilterBarSearch({
|
|
78
|
+
value,
|
|
79
|
+
onChange,
|
|
80
|
+
placeholder,
|
|
81
|
+
'aria-label': ariaLabel,
|
|
82
|
+
className,
|
|
83
|
+
}: FilterBarSearchProps) {
|
|
84
|
+
return (
|
|
85
|
+
<div className={cn('min-w-[12rem] flex-1 sm:max-w-xs', className)}>
|
|
86
|
+
<Input
|
|
87
|
+
inputSize="sm"
|
|
88
|
+
type="text"
|
|
89
|
+
placeholder={placeholder}
|
|
90
|
+
leadingAffix={<Search className="h-3.5 w-3.5" />}
|
|
91
|
+
value={value}
|
|
92
|
+
onChange={(e) => onChange(e.target.value)}
|
|
93
|
+
aria-label={ariaLabel}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Flexible spacer — everything after it is pushed to the right edge. */
|
|
100
|
+
function FilterBarSpacer() {
|
|
101
|
+
return <div className="flex-1" />
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface FilterBarCountProps {
|
|
105
|
+
shown: number
|
|
106
|
+
total: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Right-aligned "{shown} of {total}" result count, tabular-nums. */
|
|
110
|
+
function FilterBarCount({ shown, total }: FilterBarCountProps) {
|
|
111
|
+
return (
|
|
112
|
+
<span
|
|
113
|
+
className="text-[11.5px] tabular-nums"
|
|
114
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
115
|
+
>
|
|
116
|
+
{shown.toLocaleString()} of {total.toLocaleString()}
|
|
117
|
+
</span>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const FilterBar = Object.assign(FilterBarRoot, {
|
|
122
|
+
Search: FilterBarSearch,
|
|
123
|
+
Spacer: FilterBarSpacer,
|
|
124
|
+
Count: FilterBarCount,
|
|
125
|
+
})
|