@toolr/ui-design 0.1.0

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 (112) hide show
  1. package/README.md +63 -0
  2. package/components/content/info-panel-primitives.tsx +297 -0
  3. package/components/diagrams/diagram-utils.tsx +908 -0
  4. package/components/hooks/use-click-outside.ts +27 -0
  5. package/components/hooks/use-dropdown-max-height.ts +20 -0
  6. package/components/hooks/use-navigation-history.ts +94 -0
  7. package/components/lib/ai-tools.tsx +44 -0
  8. package/components/lib/cn.ts +6 -0
  9. package/components/lib/form-colors.ts +32 -0
  10. package/components/lib/theme-engine.ts +97 -0
  11. package/components/lib/toolr-brand.tsx +31 -0
  12. package/components/sections/ai-tools-paths/index.ts +37 -0
  13. package/components/sections/ai-tools-paths/tools-paths-panel.tsx +212 -0
  14. package/components/sections/ai-tools-paths/types.ts +111 -0
  15. package/components/sections/ai-tools-paths/use-tools-paths.ts +159 -0
  16. package/components/sections/captured-issues/captured-issues-panel.tsx +214 -0
  17. package/components/sections/captured-issues/index.ts +38 -0
  18. package/components/sections/captured-issues/types.ts +113 -0
  19. package/components/sections/captured-issues/use-captured-issues.ts +111 -0
  20. package/components/sections/golden-snapshots/file-diff-viewer.tsx +420 -0
  21. package/components/sections/golden-snapshots/golden-sync-panel.tsx +223 -0
  22. package/components/sections/golden-snapshots/index.ts +145 -0
  23. package/components/sections/golden-snapshots/snapshot-manager.tsx +200 -0
  24. package/components/sections/golden-snapshots/status-overview.tsx +305 -0
  25. package/components/sections/golden-snapshots/types.ts +288 -0
  26. package/components/sections/golden-snapshots/use-golden-sync.ts +477 -0
  27. package/components/sections/golden-snapshots/version-manager.tsx +186 -0
  28. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +210 -0
  29. package/components/sections/prompt-editor/index.ts +121 -0
  30. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +276 -0
  31. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +514 -0
  32. package/components/sections/prompt-editor/types.ts +101 -0
  33. package/components/sections/prompt-editor/use-prompt-editor.ts +131 -0
  34. package/components/sections/report-bug/error-logger.ts +392 -0
  35. package/components/sections/report-bug/index.ts +59 -0
  36. package/components/sections/report-bug/issue-reporter-api.ts +83 -0
  37. package/components/sections/report-bug/report-bug-form.tsx +282 -0
  38. package/components/sections/report-bug/screenshot-uploader.tsx +228 -0
  39. package/components/sections/report-bug/use-report-bug.ts +170 -0
  40. package/components/sections/snapshot-browser/index.ts +53 -0
  41. package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +147 -0
  42. package/components/sections/snapshot-browser/snapshot-tree.tsx +451 -0
  43. package/components/sections/snapshot-browser/types.ts +106 -0
  44. package/components/sections/snapshot-browser/use-snapshot-browser.ts +125 -0
  45. package/components/sections/snippets-editor/index.ts +31 -0
  46. package/components/sections/snippets-editor/snippets-editor.tsx +381 -0
  47. package/components/sections/snippets-editor/types.ts +48 -0
  48. package/components/sections/snippets-editor/use-snippets-editor.ts +217 -0
  49. package/components/ui/action-dialog.tsx +309 -0
  50. package/components/ui/ai-action-button.tsx +137 -0
  51. package/components/ui/ai-execution-action-buttons.tsx +106 -0
  52. package/components/ui/badge.tsx +67 -0
  53. package/components/ui/bottom-panel-header.tsx +240 -0
  54. package/components/ui/breadcrumb.tsx +168 -0
  55. package/components/ui/checkbox.tsx +102 -0
  56. package/components/ui/collapsible-section.tsx +100 -0
  57. package/components/ui/confirm-badge.tsx +71 -0
  58. package/components/ui/detail-section.tsx +67 -0
  59. package/components/ui/detail-view-wrapper.tsx +55 -0
  60. package/components/ui/editor-placeholder-card.tsx +197 -0
  61. package/components/ui/editor-toolbar.tsx +123 -0
  62. package/components/ui/execution-details-panel.tsx +93 -0
  63. package/components/ui/extension-list-card.tsx +105 -0
  64. package/components/ui/file-structure-section.tsx +373 -0
  65. package/components/ui/file-tree.tsx +171 -0
  66. package/components/ui/files-panel.tsx +251 -0
  67. package/components/ui/filter-dropdown.tsx +173 -0
  68. package/components/ui/form-actions.tsx +127 -0
  69. package/components/ui/frontmatter-form-header.tsx +80 -0
  70. package/components/ui/icon-button.tsx +388 -0
  71. package/components/ui/input.tsx +211 -0
  72. package/components/ui/label.tsx +159 -0
  73. package/components/ui/layout-tab-bar.tsx +289 -0
  74. package/components/ui/modal.tsx +194 -0
  75. package/components/ui/nav-card.tsx +81 -0
  76. package/components/ui/navigation-bar.tsx +285 -0
  77. package/components/ui/number-input.tsx +165 -0
  78. package/components/ui/registry-browser.tsx +261 -0
  79. package/components/ui/registry-card.tsx +710 -0
  80. package/components/ui/registry-detail.tsx +224 -0
  81. package/components/ui/resizable-textarea.tsx +290 -0
  82. package/components/ui/scope-badge.tsx +67 -0
  83. package/components/ui/segmented-toggle.tsx +133 -0
  84. package/components/ui/select.tsx +172 -0
  85. package/components/ui/selection-grid.tsx +313 -0
  86. package/components/ui/setting-row.tsx +97 -0
  87. package/components/ui/snapshot-card.tsx +107 -0
  88. package/components/ui/snippets-panel.tsx +161 -0
  89. package/components/ui/sort-dropdown.tsx +109 -0
  90. package/components/ui/status-card.tsx +96 -0
  91. package/components/ui/tab-bar.tsx +340 -0
  92. package/components/ui/toggle.tsx +142 -0
  93. package/components/ui/tooltip.tsx +326 -0
  94. package/dist/content.d.ts +110 -0
  95. package/dist/content.js +195 -0
  96. package/dist/diagrams.d.ts +371 -0
  97. package/dist/diagrams.js +702 -0
  98. package/dist/index.d.ts +2714 -0
  99. package/dist/index.js +11220 -0
  100. package/dist/preset.d.ts +24 -0
  101. package/dist/preset.js +17 -0
  102. package/dist/tokens/tokens/primitives.css +45 -0
  103. package/dist/tokens/tokens/semantic.css +46 -0
  104. package/dist/tokens/tokens/theme.css +11 -0
  105. package/dist/tokens/tokens/tokens.json +65 -0
  106. package/index.ts +123 -0
  107. package/package.json +63 -0
  108. package/tailwind-preset.ts +22 -0
  109. package/tokens/primitives.css +45 -0
  110. package/tokens/semantic.css +46 -0
  111. package/tokens/theme.css +11 -0
  112. package/tokens/tokens.json +65 -0
@@ -0,0 +1,171 @@
1
+ import { FileCode, Folder, ChevronRight, ChevronDown } from 'lucide-react'
2
+
3
+ export interface FileTreeNode {
4
+ name: string
5
+ type: 'file' | 'directory'
6
+ children?: FileTreeNode[]
7
+ }
8
+
9
+ export interface FileTreeProps {
10
+ nodes: FileTreeNode[]
11
+ rootName?: string
12
+ selectedPath: string | null
13
+ onSelectFile: (path: string) => void
14
+ prefix?: string
15
+ expandedPaths: Set<string>
16
+ onTogglePath: (path: string) => void
17
+ accentColor?: string
18
+ }
19
+
20
+ const ACCENT_SELECTED: Record<string, string> = {
21
+ blue: 'bg-blue-400/20 text-neutral-200',
22
+ purple: 'bg-purple-400/20 text-neutral-200',
23
+ orange: 'bg-orange-400/20 text-neutral-200',
24
+ green: 'bg-green-400/20 text-neutral-200',
25
+ pink: 'bg-pink-400/20 text-neutral-200',
26
+ amber: 'bg-amber-400/20 text-neutral-200',
27
+ emerald: 'bg-emerald-400/20 text-neutral-200',
28
+ teal: 'bg-teal-400/20 text-neutral-200',
29
+ sky: 'bg-sky-400/20 text-neutral-200',
30
+ }
31
+
32
+ const ACCENT_ICON: Record<string, string> = {
33
+ blue: 'text-blue-400',
34
+ purple: 'text-purple-400',
35
+ orange: 'text-orange-400',
36
+ green: 'text-green-400',
37
+ pink: 'text-pink-400',
38
+ amber: 'text-amber-400',
39
+ emerald: 'text-emerald-400',
40
+ teal: 'text-teal-400',
41
+ sky: 'text-sky-400',
42
+ }
43
+
44
+ function nodeHasFiles(node: FileTreeNode): boolean {
45
+ if (node.type === 'file') return true
46
+ return !!node.children?.some(nodeHasFiles)
47
+ }
48
+
49
+ /** Collect all directory paths that would be rendered in the tree */
50
+ export function collectDirPaths(nodes: FileTreeNode[], rootName?: string, prefix = ''): Set<string> {
51
+ const paths = new Set<string>()
52
+ function walk(children: FileTreeNode[], pathPrefix: string) {
53
+ for (const node of children.filter(nodeHasFiles)) {
54
+ if (node.type === 'directory') {
55
+ const path = pathPrefix ? `${pathPrefix}/${node.name}` : node.name
56
+ paths.add(path)
57
+ if (node.children) walk(node.children, path)
58
+ }
59
+ }
60
+ }
61
+ if (rootName) {
62
+ paths.add(rootName)
63
+ walk(nodes, rootName)
64
+ } else {
65
+ walk(nodes, prefix)
66
+ }
67
+ return paths
68
+ }
69
+
70
+ export function FileTree({ nodes, rootName, selectedPath, onSelectFile, prefix = '', expandedPaths, onTogglePath, accentColor = 'blue' }: FileTreeProps) {
71
+ if (rootName) {
72
+ const rootNode: FileTreeNode = { name: rootName, type: 'directory', children: nodes }
73
+ return (
74
+ <ul className="space-y-0.5">
75
+ <FileTreeNodeItem
76
+ node={rootNode}
77
+ path={rootName}
78
+ selectedPath={selectedPath}
79
+ onSelectFile={onSelectFile}
80
+ expandedPaths={expandedPaths}
81
+ onTogglePath={onTogglePath}
82
+ accentColor={accentColor}
83
+ />
84
+ </ul>
85
+ )
86
+ }
87
+
88
+ return (
89
+ <ul className="space-y-0.5">
90
+ {nodes.filter(nodeHasFiles).map((node) => {
91
+ const fullPath = prefix ? `${prefix}/${node.name}` : node.name
92
+ return (
93
+ <FileTreeNodeItem
94
+ key={node.name}
95
+ node={node}
96
+ path={fullPath}
97
+ selectedPath={selectedPath}
98
+ onSelectFile={onSelectFile}
99
+ expandedPaths={expandedPaths}
100
+ onTogglePath={onTogglePath}
101
+ accentColor={accentColor}
102
+ />
103
+ )
104
+ })}
105
+ </ul>
106
+ )
107
+ }
108
+
109
+ interface FileTreeNodeItemProps {
110
+ node: FileTreeNode
111
+ path: string
112
+ selectedPath: string | null
113
+ onSelectFile: (path: string) => void
114
+ expandedPaths: Set<string>
115
+ onTogglePath: (path: string) => void
116
+ accentColor: string
117
+ }
118
+
119
+ function FileTreeNodeItem({ node, path, selectedPath, onSelectFile, expandedPaths, onTogglePath, accentColor }: FileTreeNodeItemProps) {
120
+ const isDir = node.type === 'directory'
121
+ const isSelected = !isDir && selectedPath === path
122
+ const expanded = isDir && expandedPaths.has(path)
123
+ const base = 'flex items-center gap-1.5 py-0.5 px-1 rounded text-xs transition-colors overflow-hidden whitespace-nowrap'
124
+ const selectedClass = ACCENT_SELECTED[accentColor] ?? ACCENT_SELECTED.blue
125
+ const iconColorClass = ACCENT_ICON[accentColor] ?? ACCENT_ICON.blue
126
+ const rowClass = isSelected
127
+ ? `${base} ${selectedClass}`
128
+ : isDir
129
+ ? `${base} cursor-pointer hover:text-neutral-200 text-neutral-400`
130
+ : `${base} cursor-pointer hover:bg-neutral-700/50 hover:text-neutral-200 text-neutral-400`
131
+
132
+ return (
133
+ <li>
134
+ <button
135
+ onClick={isDir ? () => onTogglePath(path) : () => onSelectFile(path)}
136
+ className={rowClass}
137
+ >
138
+ {isDir ? (
139
+ expanded ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronRight className="w-3 h-3 shrink-0" />
140
+ ) : (
141
+ <span className="w-3" />
142
+ )}
143
+ {isDir ? (
144
+ <Folder className={`w-3.5 h-3.5 shrink-0 ${iconColorClass}`} />
145
+ ) : (
146
+ <FileCode className={`w-3.5 h-3.5 shrink-0 ${isSelected ? iconColorClass : 'text-neutral-500'}`} />
147
+ )}
148
+ <span className="truncate">{node.name}</span>
149
+ </button>
150
+ {isDir && expanded && node.children && (
151
+ <ul className="ml-4 space-y-0.5">
152
+ {node.children.filter(nodeHasFiles).map((child) => {
153
+ const childPath = `${path}/${child.name}`
154
+ return (
155
+ <FileTreeNodeItem
156
+ key={child.name}
157
+ node={child}
158
+ path={childPath}
159
+ selectedPath={selectedPath}
160
+ onSelectFile={onSelectFile}
161
+ expandedPaths={expandedPaths}
162
+ onTogglePath={onTogglePath}
163
+ accentColor={accentColor}
164
+ />
165
+ )
166
+ })}
167
+ </ul>
168
+ )}
169
+ </li>
170
+ )
171
+ }
@@ -0,0 +1,251 @@
1
+ /** File explorer panel with tree view, folder collapse/expand, search, and file selection. */
2
+
3
+ import { useState, useMemo } from 'react'
4
+ import { Folder, FolderOpen, File, FileCode, FileText, FileJson, Image, ChevronRight, Search, MoreVertical } from 'lucide-react'
5
+ import type { LucideIcon } from 'lucide-react'
6
+ import type { IconName } from './icon-button.tsx'
7
+ import { cn } from '../lib/cn.ts'
8
+
9
+ const iconSubset: Partial<Record<IconName, LucideIcon>> = {
10
+ folder: Folder,
11
+ file: File,
12
+ code: FileCode,
13
+ image: Image,
14
+ search: Search,
15
+ }
16
+
17
+ const EXTENSION_ICON_MAP: Record<string, LucideIcon> = {
18
+ ts: FileCode,
19
+ tsx: FileCode,
20
+ js: FileCode,
21
+ jsx: FileCode,
22
+ json: FileJson,
23
+ md: FileText,
24
+ mdx: FileText,
25
+ txt: FileText,
26
+ png: Image,
27
+ jpg: Image,
28
+ jpeg: Image,
29
+ svg: Image,
30
+ gif: Image,
31
+ webp: Image,
32
+ }
33
+
34
+ export interface FileEntry {
35
+ path: string
36
+ name: string
37
+ type: 'file' | 'folder'
38
+ icon?: IconName
39
+ color?: string
40
+ badge?: string
41
+ children?: FileEntry[]
42
+ }
43
+
44
+ export interface FilesPanelProps {
45
+ files: FileEntry[]
46
+ selectedPath?: string
47
+ onSelect?: (path: string) => void
48
+ onAction?: (action: string, path: string) => void
49
+ showSearch?: boolean
50
+ className?: string
51
+ }
52
+
53
+ function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
54
+ const paths = new Set<string>()
55
+ function walk(items: FileEntry[]) {
56
+ for (const entry of items) {
57
+ if (entry.type === 'folder') {
58
+ paths.add(entry.path)
59
+ if (entry.children) walk(entry.children)
60
+ }
61
+ }
62
+ }
63
+ walk(entries)
64
+ return paths
65
+ }
66
+
67
+ function countFiles(entries: FileEntry[]): number {
68
+ let count = 0
69
+ for (const entry of entries) {
70
+ if (entry.type === 'file') count++
71
+ if (entry.children) count += countFiles(entry.children)
72
+ }
73
+ return count
74
+ }
75
+
76
+ function getFileIcon(entry: FileEntry): LucideIcon {
77
+ if (entry.icon && iconSubset[entry.icon]) return iconSubset[entry.icon]!
78
+ if (entry.type === 'folder') return Folder
79
+ const ext = entry.name.split('.').pop()?.toLowerCase()
80
+ if (ext && EXTENSION_ICON_MAP[ext]) return EXTENSION_ICON_MAP[ext]
81
+ return File
82
+ }
83
+
84
+ function getFolderIcon(expanded: boolean, entry: FileEntry): LucideIcon {
85
+ if (entry.icon && iconSubset[entry.icon]) return iconSubset[entry.icon]!
86
+ return expanded ? FolderOpen : Folder
87
+ }
88
+
89
+ function filterTree(entries: FileEntry[], query: string): FileEntry[] {
90
+ const q = query.toLowerCase()
91
+ const result: FileEntry[] = []
92
+ for (const entry of entries) {
93
+ if (entry.type === 'file') {
94
+ if (entry.name.toLowerCase().includes(q)) result.push(entry)
95
+ } else {
96
+ const filteredChildren = entry.children ? filterTree(entry.children, query) : []
97
+ if (entry.name.toLowerCase().includes(q) || filteredChildren.length > 0) {
98
+ result.push({ ...entry, children: filteredChildren.length > 0 ? filteredChildren : entry.children })
99
+ }
100
+ }
101
+ }
102
+ return result
103
+ }
104
+
105
+ interface FileNodeProps {
106
+ entry: FileEntry
107
+ depth: number
108
+ selectedPath?: string
109
+ expandedPaths: Set<string>
110
+ onToggleExpand: (path: string) => void
111
+ onSelect?: (path: string) => void
112
+ onAction?: (action: string, path: string) => void
113
+ }
114
+
115
+ function FileNode({ entry, depth, selectedPath, expandedPaths, onToggleExpand, onSelect, onAction }: FileNodeProps) {
116
+ const isFolder = entry.type === 'folder'
117
+ const isExpanded = isFolder && expandedPaths.has(entry.path)
118
+ const isSelected = !isFolder && selectedPath === entry.path
119
+ const Icon = isFolder ? getFolderIcon(isExpanded, entry) : getFileIcon(entry)
120
+
121
+ return (
122
+ <li>
123
+ <button
124
+ type="button"
125
+ onClick={isFolder ? () => onToggleExpand(entry.path) : () => onSelect?.(entry.path)}
126
+ className={cn(
127
+ 'group flex items-center gap-1.5 w-full py-1 px-2 rounded text-xs transition-colors cursor-pointer',
128
+ isSelected
129
+ ? 'bg-blue-400/15 text-blue-400'
130
+ : 'text-neutral-400 hover:bg-neutral-700/40 hover:text-neutral-200',
131
+ )}
132
+ style={{ paddingLeft: `${depth * 16 + 8}px` }}
133
+ >
134
+ {isFolder ? (
135
+ <ChevronRight
136
+ className={cn('w-3 h-3 shrink-0 transition-transform', isExpanded && 'rotate-90')}
137
+ />
138
+ ) : (
139
+ <span className="w-3 shrink-0" />
140
+ )}
141
+ <Icon
142
+ className="w-3.5 h-3.5 shrink-0"
143
+ style={entry.color ? { color: entry.color } : undefined}
144
+ />
145
+ <span className="truncate">{entry.name}</span>
146
+ {entry.badge && (
147
+ <span className="ml-auto shrink-0 px-1.5 py-0.5 text-[9px] rounded bg-neutral-700 text-neutral-500">
148
+ {entry.badge}
149
+ </span>
150
+ )}
151
+ {onAction && (
152
+ <span
153
+ role="button"
154
+ tabIndex={-1}
155
+ className="ml-auto shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-neutral-700 text-neutral-500 hover:text-neutral-200 transition-all"
156
+ onClick={(e) => { e.stopPropagation(); onAction('menu', entry.path) }}
157
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onAction('menu', entry.path) } }}
158
+ >
159
+ <MoreVertical className="w-3 h-3" />
160
+ </span>
161
+ )}
162
+ </button>
163
+ {isFolder && isExpanded && entry.children && (
164
+ <ul>
165
+ {entry.children.map((child) => (
166
+ <FileNode
167
+ key={child.path}
168
+ entry={child}
169
+ depth={depth + 1}
170
+ selectedPath={selectedPath}
171
+ expandedPaths={expandedPaths}
172
+ onToggleExpand={onToggleExpand}
173
+ onSelect={onSelect}
174
+ onAction={onAction}
175
+ />
176
+ ))}
177
+ </ul>
178
+ )}
179
+ </li>
180
+ )
181
+ }
182
+
183
+ export function FilesPanel({
184
+ files,
185
+ selectedPath,
186
+ onSelect,
187
+ onAction,
188
+ showSearch = false,
189
+ className,
190
+ }: FilesPanelProps) {
191
+ const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => collectAllFolderPaths(files))
192
+ const [searchQuery, setSearchQuery] = useState('')
193
+
194
+ const fileCount = useMemo(() => countFiles(files), [files])
195
+
196
+ const displayedFiles = useMemo(
197
+ () => searchQuery ? filterTree(files, searchQuery) : files,
198
+ [files, searchQuery],
199
+ )
200
+
201
+ function handleToggleExpand(path: string) {
202
+ setExpandedPaths((prev: Set<string>) => {
203
+ const next = new Set(prev)
204
+ if (next.has(path)) next.delete(path)
205
+ else next.add(path)
206
+ return next
207
+ })
208
+ }
209
+
210
+ return (
211
+ <div className={cn('flex flex-col bg-neutral-800 rounded-lg overflow-hidden', className)}>
212
+ <div className="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
213
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-neutral-500">Files</span>
214
+ <span className="text-[10px] text-neutral-500">{fileCount} files</span>
215
+ </div>
216
+ {showSearch && (
217
+ <div className="px-2 py-2 border-b border-neutral-700">
218
+ <div className="flex items-center gap-1.5 px-2 py-1 bg-black border border-neutral-700 rounded text-xs">
219
+ <Search className="w-3 h-3 text-neutral-500 shrink-0" />
220
+ <input
221
+ type="text"
222
+ placeholder="Search files..."
223
+ value={searchQuery}
224
+ onChange={(e) => setSearchQuery(e.target.value)}
225
+ className="flex-1 bg-transparent text-neutral-200 placeholder-neutral-500 outline-none text-xs"
226
+ />
227
+ </div>
228
+ </div>
229
+ )}
230
+ <div className="flex-1 overflow-y-auto py-1">
231
+ <ul>
232
+ {displayedFiles.map((entry: FileEntry) => (
233
+ <FileNode
234
+ key={entry.path}
235
+ entry={entry}
236
+ depth={0}
237
+ selectedPath={selectedPath}
238
+ expandedPaths={expandedPaths}
239
+ onToggleExpand={handleToggleExpand}
240
+ onSelect={onSelect}
241
+ onAction={onAction}
242
+ />
243
+ ))}
244
+ </ul>
245
+ {displayedFiles.length === 0 && (
246
+ <p className="text-[11px] text-neutral-500 text-center py-4">No files found</p>
247
+ )}
248
+ </div>
249
+ </div>
250
+ )
251
+ }
@@ -0,0 +1,173 @@
1
+ import { useState, useEffect, useRef, type ReactNode } from 'react'
2
+ import { ChevronDown, Check, X, Search, Filter } from 'lucide-react'
3
+ import { useClickOutside } from '../hooks/use-click-outside.ts'
4
+ import { useDropdownMaxHeight } from '../hooks/use-dropdown-max-height.ts'
5
+ import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
6
+
7
+ const SEARCH_THRESHOLD = 20
8
+
9
+ const VARIANT_CLASSES = {
10
+ filled: { bg: 'bg-neutral-800', hoverBg: 'hover:bg-neutral-700' },
11
+ outline: { bg: 'bg-transparent', hoverBg: 'hover:bg-neutral-800' },
12
+ }
13
+
14
+ export interface FilterDropdownProps {
15
+ value: string
16
+ onChange: (value: string) => void
17
+ options: { value: string; label: string }[]
18
+ allLabel: string
19
+ labelExtra?: ReactNode
20
+ clearable?: boolean
21
+ variant?: 'filled' | 'outline'
22
+ color?: FormColor
23
+ }
24
+
25
+ export function FilterDropdown({
26
+ value,
27
+ onChange,
28
+ options,
29
+ allLabel,
30
+ labelExtra,
31
+ clearable = true,
32
+ variant = 'outline',
33
+ color = 'blue',
34
+ }: FilterDropdownProps) {
35
+ const [isOpen, setIsOpen] = useState(false)
36
+ const [search, setSearch] = useState('')
37
+ const [highlightIdx, setHighlightIdx] = useState(-1)
38
+ const ref = useRef<HTMLDivElement>(null)
39
+ const menuRef = useDropdownMaxHeight<HTMLDivElement>(isOpen)
40
+ const searchRef = useRef<HTMLInputElement>(null)
41
+ const isActive = value !== 'all'
42
+ const showSearch = options.length > SEARCH_THRESHOLD
43
+ const v = VARIANT_CLASSES[variant]
44
+
45
+ useClickOutside(ref, isOpen, () => setIsOpen(false))
46
+
47
+ useEffect(() => {
48
+ if (!isOpen) { setSearch(''); setHighlightIdx(-1) }
49
+ else if (showSearch) requestAnimationFrame(() => searchRef.current?.focus())
50
+ }, [isOpen, showSearch])
51
+
52
+ useEffect(() => { setHighlightIdx(-1) }, [search])
53
+
54
+ useEffect(() => {
55
+ if (highlightIdx >= 0 && menuRef.current) {
56
+ menuRef.current.querySelector<HTMLElement>(`[data-idx="${highlightIdx}"]`)?.scrollIntoView({ block: 'nearest' })
57
+ }
58
+ }, [highlightIdx, menuRef])
59
+
60
+ const selectedLabel = isActive ? (options.find((o) => o.value === value)?.label || value) : allLabel
61
+ const filtered = showSearch && search
62
+ ? options.filter((o) => o.label.toLowerCase().includes(search.toLowerCase()))
63
+ : options
64
+
65
+ const hasAllOption = clearable && !search
66
+ const itemCount = filtered.length + (hasAllOption ? 1 : 0)
67
+
68
+ const handleSelect = (val: string) => { onChange(val); setIsOpen(false) }
69
+
70
+ const handleKeyDown = (e: React.KeyboardEvent) => {
71
+ if (!isOpen) return
72
+ if (e.key === 'ArrowDown') {
73
+ e.preventDefault()
74
+ setHighlightIdx((i) => Math.min(i + 1, itemCount - 1))
75
+ } else if (e.key === 'ArrowUp') {
76
+ e.preventDefault()
77
+ setHighlightIdx((i) => Math.max(i - 1, 0))
78
+ } else if (e.key === 'Enter' && highlightIdx >= 0) {
79
+ e.preventDefault()
80
+ const offset = hasAllOption ? 1 : 0
81
+ handleSelect(hasAllOption && highlightIdx === 0 ? 'all' : filtered[highlightIdx - offset].value)
82
+ } else if (e.key === 'Escape') {
83
+ e.preventDefault()
84
+ setIsOpen(false)
85
+ }
86
+ }
87
+
88
+ return (
89
+ <div className="relative flex items-center" ref={ref} onKeyDown={handleKeyDown}>
90
+ <button
91
+ onClick={() => setIsOpen(!isOpen)}
92
+ className={`flex items-center gap-1.5 h-7 px-2 rounded-md border ${v.bg} text-xs transition-colors cursor-pointer ${
93
+ isActive
94
+ ? `${clearable ? 'rounded-r-none border-r-0' : ''} ${FORM_COLORS[color].border} text-neutral-200 ${FORM_COLORS[color].hover}`
95
+ : isOpen
96
+ ? `${FORM_COLORS[color].border} text-neutral-200`
97
+ : `${FORM_COLORS[color].border} text-neutral-400 ${FORM_COLORS[color].hover} hover:text-neutral-200`
98
+ }`}
99
+ >
100
+ <Filter className={`w-3 h-3 ${isActive ? FORM_COLORS[color].accent : ''}`} />
101
+ {labelExtra}
102
+ <span className="whitespace-nowrap">{selectedLabel}</span>
103
+ <ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
104
+ </button>
105
+ {isActive && clearable && (
106
+ <button
107
+ onClick={() => onChange('all')}
108
+ className={`flex items-center justify-center h-7 px-1.5 rounded-r-md border border-l-0 ${FORM_COLORS[color].border} ${v.bg} text-neutral-400 ${FORM_COLORS[color].hover} hover:text-neutral-200 transition-colors cursor-pointer`}
109
+ >
110
+ <X className="w-3 h-3" />
111
+ </button>
112
+ )}
113
+
114
+ {isOpen && (
115
+ <div ref={menuRef} className={`absolute right-0 top-full z-50 mt-1 min-w-[140px] whitespace-nowrap ${v.bg} border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}>
116
+ {showSearch && (
117
+ <div className={`sticky top-0 p-1.5 ${v.bg} border-b ${FORM_COLORS[color].border} z-10`}>
118
+ <div className="relative">
119
+ <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-neutral-500" />
120
+ <input
121
+ ref={searchRef}
122
+ type="text"
123
+ value={search}
124
+ onChange={(e) => setSearch(e.target.value)}
125
+ onKeyDown={handleKeyDown}
126
+ placeholder="Search..."
127
+ className={`w-full pl-6 pr-2 py-1 text-xs bg-neutral-800 border border-neutral-600 rounded text-neutral-200 placeholder-neutral-500 outline-none ${FORM_COLORS[color].focus}`}
128
+ />
129
+ </div>
130
+ </div>
131
+ )}
132
+ {hasAllOption && (
133
+ <button
134
+ data-idx={0}
135
+ onClick={() => handleSelect('all')}
136
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer ${
137
+ highlightIdx === 0
138
+ ? 'bg-neutral-600 text-neutral-200'
139
+ : !isActive ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
140
+ }`}
141
+ >
142
+ <Check className={`w-3 h-3 shrink-0 ${!isActive ? FORM_COLORS[color].accent : 'invisible'}`} />
143
+ <span>All</span>
144
+ </button>
145
+ )}
146
+ {filtered.map((opt, i) => {
147
+ const idx = i + (hasAllOption ? 1 : 0)
148
+ const isHighlighted = highlightIdx === idx
149
+ const isSelected = value === opt.value
150
+ return (
151
+ <button
152
+ key={opt.value}
153
+ data-idx={idx}
154
+ onClick={() => handleSelect(opt.value)}
155
+ className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer ${
156
+ isHighlighted
157
+ ? 'bg-neutral-600 text-neutral-200'
158
+ : isSelected ? `${FORM_COLORS[color].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
159
+ }`}
160
+ >
161
+ <Check className={`w-3 h-3 shrink-0 ${isSelected ? FORM_COLORS[color].accent : 'invisible'}`} />
162
+ <span>{opt.label}</span>
163
+ </button>
164
+ )
165
+ })}
166
+ {showSearch && search && filtered.length === 0 && (
167
+ <div className="px-3 py-2 text-xs text-neutral-500">No matches</div>
168
+ )}
169
+ </div>
170
+ )}
171
+ </div>
172
+ )
173
+ }