@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,39 @@
1
+ /**
2
+ * FilePreviewGrid — responsive grid of FilePreviewItem tiles, sized by
3
+ * file count (1 → single col, 2 → 2-col, 3-4 → 4-col, 5+ → 5-col).
4
+ *
5
+ * Promoted from brand-ai-extension (ADR-036 D20).
6
+ */
7
+
8
+ import { cn } from '../lib/utils'
9
+ import { FilePreviewItem } from './file-preview-item'
10
+ import type { PendingFile } from './use-file-upload'
11
+
12
+ interface FilePreviewGridProps {
13
+ files: PendingFile[]
14
+ onRemove: (id: string) => void
15
+ className?: string
16
+ }
17
+
18
+ export function FilePreviewGrid({ files, onRemove, className }: FilePreviewGridProps) {
19
+ if (files.length === 0) return null
20
+
21
+ const gridCols =
22
+ files.length === 1
23
+ ? 'grid-cols-1 max-w-[120px]'
24
+ : files.length === 2
25
+ ? 'grid-cols-2 max-w-[248px]'
26
+ : files.length <= 4
27
+ ? 'grid-cols-4'
28
+ : 'grid-cols-5'
29
+
30
+ return (
31
+ <div className={cn('px-3 pt-3', className)}>
32
+ <div className={cn('grid gap-2', gridCols)}>
33
+ {files.map((file) => (
34
+ <FilePreviewItem key={file.id} pendingFile={file} onRemove={onRemove} />
35
+ ))}
36
+ </div>
37
+ </div>
38
+ )
39
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * FilePreviewItem — single file card showing thumbnail, name, size, upload
3
+ * progress, error state, and a remove button.
4
+ *
5
+ * Promoted from brand-ai-extension (ADR-036 D20).
6
+ */
7
+
8
+ import { X, AlertCircle, Loader2 } from 'lucide-react'
9
+ import { cn } from '../lib/utils'
10
+ import { FileThumbnail } from './file-thumbnail'
11
+ import type { PendingFile } from './use-file-upload'
12
+
13
+ interface FilePreviewItemProps {
14
+ pendingFile: PendingFile
15
+ onRemove: (id: string) => void
16
+ }
17
+
18
+ function formatFileSize(bytes: number): string {
19
+ if (bytes < 1024) return `${bytes} B`
20
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
21
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
22
+ }
23
+
24
+ function truncateFilename(name: string, maxLength = 20): string {
25
+ if (name.length <= maxLength) return name
26
+
27
+ const ext = name.includes('.') ? name.split('.').pop() : ''
28
+ const baseName = ext ? name.slice(0, -(ext.length + 1)) : name
29
+ const available = maxLength - (ext ? ext.length + 4 : 3)
30
+
31
+ if (available <= 0) return name.slice(0, maxLength - 3) + '...'
32
+
33
+ return `${baseName.slice(0, available)}...${ext ? `.${ext}` : ''}`
34
+ }
35
+
36
+ export function FilePreviewItem({ pendingFile, onRemove }: FilePreviewItemProps) {
37
+ const { id, file, progress, status, error, previewUrl } = pendingFile
38
+ const isUploading = status === 'uploading'
39
+ const isError = status === 'error'
40
+ const isUploaded = status === 'uploaded'
41
+
42
+ return (
43
+ <div
44
+ className={cn(
45
+ 'group relative flex flex-col gap-1 p-2',
46
+ 'border bg-[rgb(var(--background))]',
47
+ )}
48
+ style={{
49
+ borderRadius: 'var(--radius-md)',
50
+ borderColor: isError
51
+ ? 'rgb(var(--destructive) / 0.5)'
52
+ : isUploaded
53
+ ? 'rgb(var(--success) / 0.5)'
54
+ : 'rgb(var(--border))',
55
+ background: isError
56
+ ? 'rgb(var(--destructive) / 0.08)'
57
+ : isUploaded
58
+ ? 'rgb(var(--success-bg))'
59
+ : 'rgb(var(--background))',
60
+ }}
61
+ >
62
+ {/* Thumbnail */}
63
+ <div
64
+ className="relative aspect-square w-full overflow-hidden"
65
+ style={{
66
+ borderRadius: 'var(--radius-sm)',
67
+ background: 'rgb(var(--muted))',
68
+ }}
69
+ >
70
+ <FileThumbnail
71
+ file={file}
72
+ className="h-full w-full"
73
+ {...(previewUrl !== undefined ? { previewUrl } : {})}
74
+ />
75
+
76
+ {isUploading && (
77
+ <div
78
+ className="absolute inset-0 flex items-center justify-center"
79
+ style={{ background: 'rgb(var(--background) / 0.8)' }}
80
+ >
81
+ <Loader2
82
+ className="h-5 w-5 animate-spin"
83
+ style={{ color: 'rgb(var(--accent))' }}
84
+ />
85
+ </div>
86
+ )}
87
+
88
+ {isError && (
89
+ <div
90
+ className="absolute inset-0 flex items-center justify-center"
91
+ style={{ background: 'rgb(var(--destructive) / 0.2)' }}
92
+ >
93
+ <AlertCircle
94
+ className="h-5 w-5"
95
+ style={{ color: 'rgb(var(--destructive))' }}
96
+ />
97
+ </div>
98
+ )}
99
+ </div>
100
+
101
+ {/* File info */}
102
+ <div className="flex flex-col gap-0.5">
103
+ <span
104
+ className="truncate text-xs font-medium"
105
+ style={{ color: 'rgb(var(--foreground))' }}
106
+ title={file.name}
107
+ >
108
+ {truncateFilename(file.name)}
109
+ </span>
110
+ <span className="text-[10px]" style={{ color: 'rgb(var(--text-muted))' }}>
111
+ {formatFileSize(file.size)}
112
+ </span>
113
+ </div>
114
+
115
+ {/* Progress bar */}
116
+ {isUploading && (
117
+ <div
118
+ className="h-1 w-full overflow-hidden"
119
+ style={{
120
+ borderRadius: 'var(--radius-full)',
121
+ background: 'rgb(var(--muted))',
122
+ }}
123
+ >
124
+ <div
125
+ className="h-full transition-all duration-300"
126
+ style={{
127
+ width: `${progress}%`,
128
+ background: 'rgb(var(--accent))',
129
+ }}
130
+ />
131
+ </div>
132
+ )}
133
+
134
+ {/* Error message */}
135
+ {isError && error && (
136
+ <span
137
+ className="truncate text-[10px]"
138
+ style={{ color: 'rgb(var(--destructive))' }}
139
+ title={error}
140
+ >
141
+ {error}
142
+ </span>
143
+ )}
144
+
145
+ {/* Remove button */}
146
+ <button
147
+ type="button"
148
+ onClick={() => onRemove(id)}
149
+ disabled={isUploading}
150
+ className={cn(
151
+ 'absolute -right-2 -top-2 flex h-5 w-5 items-center justify-center',
152
+ 'rounded-full border transition-all',
153
+ 'shadow-[var(--shadow-sm)]',
154
+ isError
155
+ ? 'opacity-100'
156
+ : 'opacity-0 group-hover:opacity-100',
157
+ isUploading && 'pointer-events-none',
158
+ )}
159
+ style={
160
+ isError
161
+ ? {
162
+ background: 'rgb(var(--destructive))',
163
+ color: 'white',
164
+ borderColor: 'rgb(var(--destructive))',
165
+ }
166
+ : undefined
167
+ }
168
+ title={isError ? 'Remove failed upload' : 'Remove file'}
169
+ aria-label={isError ? 'Remove failed upload' : 'Remove file'}
170
+ >
171
+ <X className="h-3 w-3" />
172
+ </button>
173
+ </div>
174
+ )
175
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * FileThumbnail — icon-based preview tile for non-image files; renders the
3
+ * actual image for image files when a preview URL is available.
4
+ *
5
+ * Promoted from brand-ai-extension (ADR-036 D20).
6
+ *
7
+ * Token-boundary note: the source used Tailwind color utilities (text-red-600,
8
+ * bg-blue-100, etc.) which are raw color literals. Rewritten to use inline
9
+ * style with CSS variables from the public token set where possible, and
10
+ * for the categorical file-type tints we use the --folder-* palette tokens
11
+ * (ADR-035) which are the closest semantic color set in the design system
12
+ * for categorical accent use.
13
+ */
14
+
15
+ import { FileText, Table, Code, Image, FileType } from 'lucide-react'
16
+ import { cn } from '../lib/utils'
17
+
18
+ interface FileThumbnailProps {
19
+ file: File
20
+ previewUrl?: string
21
+ className?: string
22
+ }
23
+
24
+ type IconComponent = typeof FileText
25
+
26
+ interface ExtensionConfig {
27
+ icon: IconComponent
28
+ colorVar: string
29
+ bgVar: string
30
+ }
31
+
32
+ // Map file extensions to Lucide icons and design-system token pairs.
33
+ // Using --folder-* palette tokens (ADR-035) as categorical accents.
34
+ const EXTENSION_CONFIG: Record<string, ExtensionConfig> = {
35
+ pdf: { icon: FileText, colorVar: '--folder-red', bgVar: '--folder-red' },
36
+ docx: { icon: FileText, colorVar: '--folder-blue', bgVar: '--folder-blue' },
37
+ txt: { icon: FileText, colorVar: '--folder-slate', bgVar: '--folder-slate' },
38
+ csv: { icon: Table, colorVar: '--folder-emerald', bgVar: '--folder-emerald' },
39
+ xml: { icon: Code, colorVar: '--folder-amber', bgVar: '--folder-amber' },
40
+ html: { icon: Code, colorVar: '--folder-violet', bgVar: '--folder-violet' },
41
+ css: { icon: Code, colorVar: '--folder-blue', bgVar: '--folder-blue' },
42
+ js: { icon: Code, colorVar: '--folder-amber', bgVar: '--folder-amber' },
43
+ jpg: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
44
+ jpeg: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
45
+ png: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
46
+ gif: { icon: Image, colorVar: '--folder-pink', bgVar: '--folder-pink' },
47
+ }
48
+
49
+ const FALLBACK_CONFIG: ExtensionConfig = {
50
+ icon: FileType,
51
+ colorVar: '--folder-slate',
52
+ bgVar: '--folder-slate',
53
+ }
54
+
55
+ function getExtension(filename: string): string {
56
+ const parts = filename.split('.')
57
+ return parts.length > 1 ? (parts[parts.length - 1] ?? '').toLowerCase() : ''
58
+ }
59
+
60
+ export function FileThumbnail({ file, previewUrl, className }: FileThumbnailProps) {
61
+ const extension = getExtension(file.name)
62
+ const isImage = file.type.startsWith('image/')
63
+
64
+ if (isImage && previewUrl) {
65
+ return (
66
+ <div className={cn('relative overflow-hidden rounded-md', className)}>
67
+ <img
68
+ src={previewUrl}
69
+ alt={file.name}
70
+ className="h-full w-full object-cover"
71
+ />
72
+ </div>
73
+ )
74
+ }
75
+
76
+ const config = EXTENSION_CONFIG[extension] ?? FALLBACK_CONFIG
77
+ const IconComponent = config.icon
78
+
79
+ return (
80
+ <div
81
+ className={cn('flex items-center justify-center rounded-md', className)}
82
+ style={{ background: `rgb(var(${config.bgVar}) / 0.15)` }}
83
+ >
84
+ <div className="flex flex-col items-center gap-1">
85
+ <IconComponent
86
+ className="h-6 w-6"
87
+ style={{ color: `rgb(var(${config.colorVar}))` }}
88
+ />
89
+ <span
90
+ className="text-[10px] font-medium uppercase"
91
+ style={{ color: `rgb(var(${config.colorVar}))` }}
92
+ >
93
+ {extension || '?'}
94
+ </span>
95
+ </div>
96
+ </div>
97
+ )
98
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * FileUploadButton — paperclip trigger that opens a native file picker.
3
+ * Disables itself when the file limit is reached.
4
+ *
5
+ * Promoted from brand-ai-extension (ADR-036 D20).
6
+ */
7
+
8
+ import { useRef } from 'react'
9
+ import { Paperclip } from 'lucide-react'
10
+ import { cn } from '../lib/utils'
11
+
12
+ const ACCEPTED_TYPES = [
13
+ 'image/jpeg',
14
+ 'image/png',
15
+ 'image/gif',
16
+ 'application/pdf',
17
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
18
+ 'text/plain',
19
+ 'text/csv',
20
+ 'text/html',
21
+ 'text/css',
22
+ 'text/xml',
23
+ 'application/xml',
24
+ 'application/javascript',
25
+ 'text/javascript',
26
+ ].join(',')
27
+
28
+ interface FileUploadButtonProps {
29
+ onFilesSelected: (files: File[]) => void
30
+ disabled?: boolean
31
+ fileCount?: number
32
+ maxFiles?: number
33
+ }
34
+
35
+ export function FileUploadButton({
36
+ onFilesSelected,
37
+ disabled = false,
38
+ fileCount = 0,
39
+ maxFiles = 10,
40
+ }: FileUploadButtonProps) {
41
+ const inputRef = useRef<HTMLInputElement>(null)
42
+ const isAtLimit = fileCount >= maxFiles
43
+
44
+ const handleClick = () => {
45
+ inputRef.current?.click()
46
+ }
47
+
48
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
49
+ const files = e.target.files
50
+ if (files && files.length > 0) {
51
+ onFilesSelected(Array.from(files))
52
+ // Reset so the same file can be re-selected
53
+ e.target.value = ''
54
+ }
55
+ }
56
+
57
+ return (
58
+ <>
59
+ <input
60
+ ref={inputRef}
61
+ type="file"
62
+ multiple
63
+ accept={ACCEPTED_TYPES}
64
+ onChange={handleChange}
65
+ className="hidden"
66
+ disabled={disabled || isAtLimit}
67
+ />
68
+ <button
69
+ type="button"
70
+ onClick={handleClick}
71
+ disabled={disabled || isAtLimit}
72
+ className={cn(
73
+ 'flex h-8 w-8 items-center justify-center transition-colors',
74
+ (disabled || isAtLimit) && 'cursor-not-allowed opacity-50',
75
+ )}
76
+ style={{ borderRadius: 'var(--radius-sm)' }}
77
+ title={
78
+ isAtLimit
79
+ ? `Maximum ${maxFiles} files`
80
+ : `Attach files (${fileCount}/${maxFiles})`
81
+ }
82
+ aria-label={
83
+ isAtLimit
84
+ ? `Maximum ${maxFiles} files reached`
85
+ : `Attach files (${fileCount} of ${maxFiles})`
86
+ }
87
+ >
88
+ <Paperclip
89
+ className="h-4 w-4"
90
+ style={{ color: 'rgb(var(--muted-foreground))' }}
91
+ />
92
+ </button>
93
+ </>
94
+ )
95
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * file-upload barrel — public surface for the @lovett/ui file-upload subtree.
3
+ *
4
+ * Promoted from brand-ai-extension (ADR-036 D20).
5
+ */
6
+
7
+ export { FileUploadButton } from './file-upload-button'
8
+ export { FileDropZone } from './file-drop-zone'
9
+ export { FilePreviewGrid } from './file-preview-grid'
10
+ export { FilePreviewItem } from './file-preview-item'
11
+ export { FileThumbnail } from './file-thumbnail'
12
+ export { useFileUpload } from './use-file-upload'
13
+ export type { PendingFile, UploadResult, UseFileUploadOptions } from './use-file-upload'