@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,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
+ })