@toolr/ui-design 0.1.7 → 0.1.9
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/ai-manifest.json +35 -20
- package/components/composites/dashboard-list-item.tsx +172 -0
- package/components/composites/dashboard-panel.tsx +218 -0
- package/components/content/info-panel-primitives.tsx +9 -8
- package/components/diagrams/diagram-utils.tsx +2 -1
- package/components/hooks/use-dropdown-portal.ts +39 -0
- package/components/hooks/use-modal-behavior.ts +32 -3
- package/components/lib/accent-context.ts +10 -0
- package/components/lib/{ai-tools.tsx → coding-agents.tsx} +23 -8
- package/components/lib/custom-icons.tsx +37 -0
- package/components/lib/git-providers.tsx +39 -0
- package/components/lib/theme-engine.ts +59 -10
- package/components/lib/toolr-brand.tsx +23 -9
- package/components/sections/captured-issues/captured-issues-panel.tsx +17 -8
- package/components/sections/{ai-tools-paths/tools-paths-panel.tsx → coding-agent-paths/agent-paths-panel.tsx} +70 -62
- package/components/sections/coding-agent-paths/index.ts +37 -0
- package/components/sections/{ai-tools-paths → coding-agent-paths}/types.ts +28 -28
- package/components/sections/coding-agent-paths/use-agent-paths.ts +159 -0
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +11 -10
- package/components/sections/golden-snapshots/golden-sync-panel.tsx +12 -3
- package/components/sections/golden-snapshots/snapshot-manager.tsx +9 -7
- package/components/sections/golden-snapshots/status-overview.tsx +8 -8
- package/components/sections/golden-snapshots/version-manager.tsx +6 -6
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +3 -3
- package/components/sections/prompt-editor/index.ts +1 -1
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +13 -5
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +18 -10
- package/components/sections/prompt-editor/types.ts +2 -2
- package/components/sections/report-bug/report-bug-form.tsx +12 -4
- package/components/sections/report-bug/screenshot-uploader.tsx +11 -3
- package/components/sections/snapshot-browser/snapshot-browser-panel.tsx +12 -4
- package/components/sections/snapshot-browser/snapshot-tree.tsx +5 -4
- package/components/sections/snapshot-browser/types.ts +1 -1
- package/components/sections/snippets-editor/snippets-editor.tsx +16 -9
- package/components/settings/SettingsHeader.tsx +2 -2
- package/components/settings/SettingsPanel.tsx +11 -3
- package/components/settings/SettingsTreeNav.tsx +15 -9
- package/components/ui/action-dialog.tsx +37 -35
- package/components/ui/ai-action-button.tsx +12 -11
- package/components/ui/ai-execution-action-buttons.tsx +13 -5
- package/components/ui/badge.tsx +17 -6
- package/components/ui/bottom-panel-header.tsx +9 -5
- package/components/ui/breadcrumb.tsx +14 -6
- package/components/ui/{extension-list-card.tsx → capability-list-card.tsx} +14 -6
- package/components/ui/checkbox.tsx +23 -14
- package/components/ui/collapsible-section.tsx +38 -28
- package/components/ui/confirm-badge.tsx +17 -6
- package/components/ui/cookie-consent.tsx +13 -7
- package/components/ui/detail-section.tsx +24 -16
- package/components/ui/detail-view-wrapper.tsx +30 -22
- package/components/ui/editor-placeholder-card.tsx +28 -24
- package/components/ui/editor-toolbar.tsx +7 -4
- package/components/ui/execution-details-panel.tsx +10 -5
- package/components/ui/file-structure-section.tsx +3 -3
- package/components/ui/file-tree.tsx +7 -5
- package/components/ui/files-panel.tsx +147 -27
- package/components/ui/filter-dropdown.tsx +88 -75
- package/components/ui/form-actions.tsx +21 -11
- package/components/ui/frontmatter-form-header.tsx +10 -2
- package/components/ui/icon-button.tsx +27 -14
- package/components/ui/input.tsx +15 -7
- package/components/ui/label.tsx +9 -5
- package/components/ui/layout-tab-bar.tsx +11 -9
- package/components/ui/modal.tsx +26 -8
- package/components/ui/nav-card.tsx +7 -4
- package/components/ui/navigation-bar.tsx +40 -12
- package/components/ui/number-input.tsx +14 -4
- package/components/ui/project-explorer.tsx +666 -0
- package/components/ui/registry-browser.tsx +12 -1
- package/components/ui/registry-card.tsx +49 -42
- package/components/ui/registry-detail.tsx +34 -11
- package/components/ui/resizable-textarea.tsx +18 -11
- package/components/ui/scope-badge.tsx +18 -11
- package/components/ui/segmented-toggle.tsx +7 -2
- package/components/ui/select.tsx +17 -11
- package/components/ui/selection-grid.tsx +40 -37
- package/components/ui/setting-row.tsx +6 -4
- package/components/ui/settings-card.tsx +12 -5
- package/components/ui/settings-info-box.tsx +9 -6
- package/components/ui/settings-section-title.tsx +14 -2
- package/components/ui/snapshot-card.tsx +10 -2
- package/components/ui/snippets-panel.tsx +4 -2
- package/components/ui/sort-dropdown.tsx +45 -32
- package/components/ui/status-card.tsx +9 -1
- package/components/ui/tab-bar.tsx +26 -13
- package/components/ui/toggle.tsx +31 -17
- package/components/ui/tooltip.tsx +14 -6
- package/dist/content.js +8 -8
- package/dist/diagrams.d.ts +0 -1
- package/dist/index.d.ts +431 -186
- package/dist/index.js +3119 -1724
- package/dist/tokens/primitives.css +28 -6
- package/dist/tokens/semantic.css +15 -15
- package/dist/tokens/theme.css +23 -0
- package/index.ts +25 -11
- package/package.json +9 -1
- package/tokens/primitives.css +28 -6
- package/tokens/semantic.css +15 -15
- package/tokens/theme.css +23 -0
- package/components/sections/ai-tools-paths/index.ts +0 -37
- package/components/sections/ai-tools-paths/use-tools-paths.ts +0 -159
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/** File explorer panel with tree view, folder collapse/expand, search, and file selection. */
|
|
2
2
|
|
|
3
|
-
import { useState, useMemo } from 'react'
|
|
3
|
+
import { useState, useMemo, type ReactNode } from 'react'
|
|
4
4
|
import { Folder, FolderOpen, File, FileCode, FileText, FileJson, Image, ChevronRight, Search, MoreVertical } from 'lucide-react'
|
|
5
5
|
import type { LucideIcon } from 'lucide-react'
|
|
6
6
|
import { iconMap, type IconName } from './icon-button.tsx'
|
|
7
7
|
import { cn } from '../lib/cn.ts'
|
|
8
|
+
import { ACCENT_ICON, type AccentColor } from '../lib/form-colors.ts'
|
|
9
|
+
import { useAccentColor } from '../lib/accent-context.ts'
|
|
8
10
|
|
|
9
11
|
const ACCENT_SELECTED: Record<string, string> = {
|
|
10
12
|
blue: 'bg-blue-400/15 text-blue-400',
|
|
@@ -19,6 +21,19 @@ const ACCENT_SELECTED: Record<string, string> = {
|
|
|
19
21
|
violet: 'bg-violet-400/15 text-violet-400',
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
const ACCENT_SELECTED_BG: Record<string, string> = {
|
|
25
|
+
blue: 'bg-blue-500/20',
|
|
26
|
+
purple: 'bg-purple-500/20',
|
|
27
|
+
orange: 'bg-orange-500/20',
|
|
28
|
+
green: 'bg-green-500/20',
|
|
29
|
+
pink: 'bg-pink-500/20',
|
|
30
|
+
amber: 'bg-amber-500/20',
|
|
31
|
+
emerald: 'bg-emerald-500/20',
|
|
32
|
+
teal: 'bg-teal-500/20',
|
|
33
|
+
sky: 'bg-sky-500/20',
|
|
34
|
+
violet: 'bg-violet-500/20',
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
const EXTENSION_ICON_MAP: Record<string, LucideIcon> = {
|
|
23
38
|
ts: FileCode,
|
|
24
39
|
tsx: FileCode,
|
|
@@ -54,9 +69,29 @@ export interface FilesPanelProps {
|
|
|
54
69
|
showSearch?: boolean
|
|
55
70
|
className?: string
|
|
56
71
|
accentColor?: string
|
|
72
|
+
/** Right-click handler for tree nodes */
|
|
73
|
+
onContextMenu?: (e: React.MouseEvent, entry: FileEntry) => void
|
|
74
|
+
/** Path of the selected/highlighted folder (separate from file selection) */
|
|
75
|
+
selectedFolderPath?: string
|
|
76
|
+
/** Callback when a folder row is clicked (for folder selection). If set, chevron toggles expand while row selects. */
|
|
77
|
+
onSelectFolder?: (path: string) => void
|
|
78
|
+
/** Custom content rendered after the file/folder name (e.g. badges, labels) */
|
|
79
|
+
renderBadges?: (entry: FileEntry) => ReactNode
|
|
80
|
+
/** Custom hover actions rendered at the right edge of each row */
|
|
81
|
+
renderNodeActions?: (entry: FileEntry) => ReactNode
|
|
82
|
+
/** Set of paths that should render in a muted/dimmed style */
|
|
83
|
+
hiddenPaths?: Set<string>
|
|
84
|
+
/** Custom content above the tree (e.g. filter badges, creation inputs) */
|
|
85
|
+
header?: ReactNode
|
|
86
|
+
/** Custom footer content. When provided, replaces the default file count footer. */
|
|
87
|
+
footer?: ReactNode
|
|
88
|
+
/** Controlled expand state. When provided, FilesPanel uses this instead of internal state. */
|
|
89
|
+
expandedPaths?: Set<string>
|
|
90
|
+
/** Controlled toggle callback. Required when expandedPaths is provided. */
|
|
91
|
+
onToggleExpand?: (path: string) => void
|
|
57
92
|
}
|
|
58
93
|
|
|
59
|
-
function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
|
|
94
|
+
export function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
|
|
60
95
|
const paths = new Set<string>()
|
|
61
96
|
function walk(items: FileEntry[]) {
|
|
62
97
|
for (const entry of items) {
|
|
@@ -70,7 +105,7 @@ function collectAllFolderPaths(entries: FileEntry[]): Set<string> {
|
|
|
70
105
|
return paths
|
|
71
106
|
}
|
|
72
107
|
|
|
73
|
-
function countFiles(entries: FileEntry[]): number {
|
|
108
|
+
export function countFiles(entries: FileEntry[]): number {
|
|
74
109
|
let count = 0
|
|
75
110
|
for (const entry of entries) {
|
|
76
111
|
if (entry.type === 'file') count++
|
|
@@ -79,6 +114,17 @@ function countFiles(entries: FileEntry[]): number {
|
|
|
79
114
|
return count
|
|
80
115
|
}
|
|
81
116
|
|
|
117
|
+
export function countFolders(entries: FileEntry[]): number {
|
|
118
|
+
let count = 0
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.type === 'folder') {
|
|
121
|
+
count++
|
|
122
|
+
if (entry.children) count += countFolders(entry.children)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return count
|
|
126
|
+
}
|
|
127
|
+
|
|
82
128
|
function getFileIcon(entry: FileEntry): LucideIcon {
|
|
83
129
|
if (entry.icon && iconMap[entry.icon]) return iconMap[entry.icon]!
|
|
84
130
|
if (entry.type === 'folder') return Folder
|
|
@@ -112,61 +158,96 @@ interface FileNodeProps {
|
|
|
112
158
|
entry: FileEntry
|
|
113
159
|
depth: number
|
|
114
160
|
selectedPath?: string
|
|
161
|
+
selectedFolderPath?: string
|
|
115
162
|
expandedPaths: Set<string>
|
|
163
|
+
hiddenPaths?: Set<string>
|
|
116
164
|
onToggleExpand: (path: string) => void
|
|
117
165
|
onSelect?: (path: string) => void
|
|
166
|
+
onSelectFolder?: (path: string) => void
|
|
118
167
|
onAction?: (action: string, path: string) => void
|
|
168
|
+
onContextMenu?: (e: React.MouseEvent, entry: FileEntry) => void
|
|
169
|
+
renderBadges?: (entry: FileEntry) => ReactNode
|
|
170
|
+
renderNodeActions?: (entry: FileEntry) => ReactNode
|
|
119
171
|
accentColor: string
|
|
120
172
|
}
|
|
121
173
|
|
|
122
|
-
function FileNode({
|
|
174
|
+
function FileNode({
|
|
175
|
+
entry, depth, selectedPath, selectedFolderPath, expandedPaths, hiddenPaths,
|
|
176
|
+
onToggleExpand, onSelect, onSelectFolder, onAction, onContextMenu,
|
|
177
|
+
renderBadges, renderNodeActions, accentColor,
|
|
178
|
+
}: FileNodeProps) {
|
|
123
179
|
const isFolder = entry.type === 'folder'
|
|
124
180
|
const isExpanded = isFolder && expandedPaths.has(entry.path)
|
|
125
|
-
const
|
|
181
|
+
const isFileSelected = !isFolder && selectedPath === entry.path
|
|
182
|
+
const isFolderSelected = isFolder && selectedFolderPath === entry.path
|
|
183
|
+
const isMuted = hiddenPaths?.has(entry.path)
|
|
126
184
|
const Icon = isFolder ? getFolderIcon(isExpanded, entry) : getFileIcon(entry)
|
|
127
185
|
|
|
186
|
+
const hasFolderSelection = !!onSelectFolder
|
|
187
|
+
const handleClick = isFolder
|
|
188
|
+
? (hasFolderSelection ? () => onSelectFolder!(entry.path) : () => onToggleExpand(entry.path))
|
|
189
|
+
: () => onSelect?.(entry.path)
|
|
190
|
+
|
|
191
|
+
const isHighlighted = isFileSelected || isFolderSelected
|
|
192
|
+
|
|
128
193
|
return (
|
|
129
194
|
<li>
|
|
130
|
-
<
|
|
131
|
-
type="button"
|
|
132
|
-
onClick={isFolder ? () => onToggleExpand(entry.path) : () => onSelect?.(entry.path)}
|
|
195
|
+
<div
|
|
133
196
|
className={cn(
|
|
134
|
-
'group flex items-center gap-1.5 w-full py-1 px-2
|
|
135
|
-
|
|
136
|
-
|
|
197
|
+
'group/node relative flex items-center gap-1.5 w-full py-1 px-2 text-sm transition-colors cursor-pointer select-none',
|
|
198
|
+
isMuted && 'opacity-50',
|
|
199
|
+
isHighlighted
|
|
200
|
+
? (isFolderSelected ? ACCENT_SELECTED_BG[accentColor] ?? ACCENT_SELECTED_BG.blue : ACCENT_SELECTED[accentColor] ?? ACCENT_SELECTED.blue)
|
|
137
201
|
: 'text-neutral-400 hover:bg-neutral-700/40 hover:text-neutral-200',
|
|
138
202
|
)}
|
|
139
203
|
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
|
204
|
+
onClick={handleClick}
|
|
205
|
+
onContextMenu={onContextMenu ? (e) => onContextMenu(e, entry) : undefined}
|
|
140
206
|
>
|
|
141
207
|
{isFolder ? (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
208
|
+
hasFolderSelection ? (
|
|
209
|
+
// Chevron is a separate click target when folders have selection behavior
|
|
210
|
+
<span
|
|
211
|
+
className="shrink-0 p-0.5 -m-0.5 rounded hover:bg-neutral-600/40 cursor-pointer"
|
|
212
|
+
onClick={(e) => { e.stopPropagation(); onToggleExpand(entry.path) }}
|
|
213
|
+
>
|
|
214
|
+
<ChevronRight className={cn('w-3 h-3 transition-transform', isExpanded && 'rotate-90')} />
|
|
215
|
+
</span>
|
|
216
|
+
) : (
|
|
217
|
+
<ChevronRight className={cn('w-3 h-3 shrink-0 transition-transform', isExpanded && 'rotate-90')} />
|
|
218
|
+
)
|
|
145
219
|
) : (
|
|
146
220
|
<span className="w-3 shrink-0" />
|
|
147
221
|
)}
|
|
148
222
|
<Icon
|
|
149
|
-
className=
|
|
223
|
+
className={cn('w-3.5 h-3.5 shrink-0', !entry.color && (ACCENT_ICON[accentColor as AccentColor] ?? ACCENT_ICON.blue))}
|
|
150
224
|
style={entry.color ? { color: entry.color } : undefined}
|
|
151
225
|
/>
|
|
152
226
|
<span className="truncate">{entry.name}</span>
|
|
153
|
-
{entry.badge && (
|
|
227
|
+
{renderBadges ? renderBadges(entry) : entry.badge && (
|
|
154
228
|
<span className="ml-auto shrink-0 px-1.5 py-0.5 text-xs rounded bg-neutral-700 text-neutral-500">
|
|
155
229
|
{entry.badge}
|
|
156
230
|
</span>
|
|
157
231
|
)}
|
|
158
|
-
{
|
|
232
|
+
{renderNodeActions ? (
|
|
233
|
+
<div
|
|
234
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 hidden group-hover/node:flex items-center gap-0.5 bg-neutral-960/90 rounded px-0.5"
|
|
235
|
+
onClick={(e) => e.stopPropagation()}
|
|
236
|
+
>
|
|
237
|
+
{renderNodeActions(entry)}
|
|
238
|
+
</div>
|
|
239
|
+
) : onAction && (
|
|
159
240
|
<span
|
|
160
241
|
role="button"
|
|
161
242
|
tabIndex={-1}
|
|
162
|
-
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"
|
|
243
|
+
className="ml-auto shrink-0 opacity-0 group-hover/node:opacity-100 p-0.5 rounded hover:bg-neutral-700 text-neutral-500 hover:text-neutral-200 transition-all"
|
|
163
244
|
onClick={(e) => { e.stopPropagation(); onAction('menu', entry.path) }}
|
|
164
245
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onAction('menu', entry.path) } }}
|
|
165
246
|
>
|
|
166
247
|
<MoreVertical className="w-3 h-3" />
|
|
167
248
|
</span>
|
|
168
249
|
)}
|
|
169
|
-
</
|
|
250
|
+
</div>
|
|
170
251
|
{isFolder && isExpanded && entry.children && (
|
|
171
252
|
<ul>
|
|
172
253
|
{entry.children.map((child) => (
|
|
@@ -175,10 +256,16 @@ function FileNode({ entry, depth, selectedPath, expandedPaths, onToggleExpand, o
|
|
|
175
256
|
entry={child}
|
|
176
257
|
depth={depth + 1}
|
|
177
258
|
selectedPath={selectedPath}
|
|
259
|
+
selectedFolderPath={selectedFolderPath}
|
|
178
260
|
expandedPaths={expandedPaths}
|
|
261
|
+
hiddenPaths={hiddenPaths}
|
|
179
262
|
onToggleExpand={onToggleExpand}
|
|
180
263
|
onSelect={onSelect}
|
|
264
|
+
onSelectFolder={onSelectFolder}
|
|
181
265
|
onAction={onAction}
|
|
266
|
+
onContextMenu={onContextMenu}
|
|
267
|
+
renderBadges={renderBadges}
|
|
268
|
+
renderNodeActions={renderNodeActions}
|
|
182
269
|
accentColor={accentColor}
|
|
183
270
|
/>
|
|
184
271
|
))}
|
|
@@ -195,11 +282,25 @@ export function FilesPanel({
|
|
|
195
282
|
onAction,
|
|
196
283
|
showSearch = false,
|
|
197
284
|
className,
|
|
198
|
-
accentColor
|
|
285
|
+
accentColor: accentColorProp,
|
|
286
|
+
onContextMenu,
|
|
287
|
+
selectedFolderPath,
|
|
288
|
+
onSelectFolder,
|
|
289
|
+
renderBadges,
|
|
290
|
+
renderNodeActions,
|
|
291
|
+
hiddenPaths,
|
|
292
|
+
header,
|
|
293
|
+
footer,
|
|
294
|
+
expandedPaths: controlledExpandedPaths,
|
|
295
|
+
onToggleExpand: controlledOnToggleExpand,
|
|
199
296
|
}: FilesPanelProps) {
|
|
200
|
-
const
|
|
297
|
+
const accentColor = accentColorProp ?? useAccentColor() ?? 'blue'
|
|
298
|
+
const [internalExpandedPaths, setInternalExpandedPaths] = useState<Set<string>>(() => collectAllFolderPaths(files))
|
|
201
299
|
const [searchQuery, setSearchQuery] = useState('')
|
|
202
300
|
|
|
301
|
+
const isControlled = controlledExpandedPaths !== undefined
|
|
302
|
+
const expandedPaths = isControlled ? controlledExpandedPaths : internalExpandedPaths
|
|
303
|
+
|
|
203
304
|
const fileCount = useMemo(() => countFiles(files), [files])
|
|
204
305
|
|
|
205
306
|
const displayedFiles = useMemo(
|
|
@@ -208,7 +309,11 @@ export function FilesPanel({
|
|
|
208
309
|
)
|
|
209
310
|
|
|
210
311
|
function handleToggleExpand(path: string) {
|
|
211
|
-
|
|
312
|
+
if (isControlled) {
|
|
313
|
+
controlledOnToggleExpand?.(path)
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
setInternalExpandedPaths((prev: Set<string>) => {
|
|
212
317
|
const next = new Set(prev)
|
|
213
318
|
if (next.has(path)) next.delete(path)
|
|
214
319
|
else next.add(path)
|
|
@@ -217,11 +322,14 @@ export function FilesPanel({
|
|
|
217
322
|
}
|
|
218
323
|
|
|
219
324
|
return (
|
|
220
|
-
<div className={cn('flex flex-col bg-neutral-
|
|
221
|
-
|
|
222
|
-
<
|
|
223
|
-
|
|
224
|
-
|
|
325
|
+
<div className={cn('flex flex-col bg-neutral-960 rounded-lg overflow-hidden', className)}>
|
|
326
|
+
{!header && (
|
|
327
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-700">
|
|
328
|
+
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">Files</span>
|
|
329
|
+
<span className="text-xs text-neutral-500">{fileCount} files</span>
|
|
330
|
+
</div>
|
|
331
|
+
)}
|
|
332
|
+
{header}
|
|
225
333
|
{showSearch && (
|
|
226
334
|
<div className="px-2 py-2 border-b border-neutral-700">
|
|
227
335
|
<div className="flex items-center gap-1.5 px-2 py-1 bg-[var(--background)] border border-neutral-700 rounded text-sm">
|
|
@@ -244,10 +352,16 @@ export function FilesPanel({
|
|
|
244
352
|
entry={entry}
|
|
245
353
|
depth={0}
|
|
246
354
|
selectedPath={selectedPath}
|
|
355
|
+
selectedFolderPath={selectedFolderPath}
|
|
247
356
|
expandedPaths={expandedPaths}
|
|
357
|
+
hiddenPaths={hiddenPaths}
|
|
248
358
|
onToggleExpand={handleToggleExpand}
|
|
249
359
|
onSelect={onSelect}
|
|
360
|
+
onSelectFolder={onSelectFolder}
|
|
250
361
|
onAction={onAction}
|
|
362
|
+
onContextMenu={onContextMenu}
|
|
363
|
+
renderBadges={renderBadges}
|
|
364
|
+
renderNodeActions={renderNodeActions}
|
|
251
365
|
accentColor={accentColor}
|
|
252
366
|
/>
|
|
253
367
|
))}
|
|
@@ -256,6 +370,12 @@ export function FilesPanel({
|
|
|
256
370
|
<p className="text-xs text-neutral-500 text-center py-4">No files found</p>
|
|
257
371
|
)}
|
|
258
372
|
</div>
|
|
373
|
+
{footer}
|
|
374
|
+
{!footer && !header && (
|
|
375
|
+
<div className="flex items-center justify-between px-3 py-2 border-t border-neutral-700">
|
|
376
|
+
<span className="text-xs text-neutral-500">{fileCount} files</span>
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
259
379
|
</div>
|
|
260
380
|
)
|
|
261
381
|
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { useState, useEffect,
|
|
1
|
+
import { useState, useEffect, type ReactNode } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import { ChevronDown, Check, X, Search, Filter } from 'lucide-react'
|
|
3
4
|
import { useClickOutside } from '../hooks/use-click-outside.ts'
|
|
4
|
-
import {
|
|
5
|
+
import { useDropdownPortal } from '../hooks/use-dropdown-portal.ts'
|
|
5
6
|
import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
|
|
7
|
+
import { useAccentColor } from '../lib/accent-context.ts'
|
|
6
8
|
|
|
7
9
|
const SEARCH_THRESHOLD = 20
|
|
8
10
|
|
|
9
11
|
const VARIANT_CLASSES = {
|
|
10
|
-
filled: { bg: 'bg-neutral-
|
|
12
|
+
filled: { bg: 'bg-neutral-960', hoverBg: 'hover:bg-neutral-700' },
|
|
11
13
|
outline: { bg: 'bg-transparent', hoverBg: 'hover:bg-neutral-700' },
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -19,7 +21,7 @@ export interface FilterDropdownProps {
|
|
|
19
21
|
labelExtra?: ReactNode
|
|
20
22
|
clearable?: boolean
|
|
21
23
|
variant?: 'filled' | 'outline'
|
|
22
|
-
|
|
24
|
+
accentColor?: FormColor
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function FilterDropdown({
|
|
@@ -30,19 +32,20 @@ export function FilterDropdown({
|
|
|
30
32
|
labelExtra,
|
|
31
33
|
clearable = true,
|
|
32
34
|
variant = 'outline',
|
|
33
|
-
|
|
35
|
+
accentColor,
|
|
34
36
|
}: FilterDropdownProps) {
|
|
37
|
+
const contextAccent = useAccentColor()
|
|
38
|
+
const effectiveColor = accentColor ?? contextAccent ?? 'blue'
|
|
35
39
|
const [isOpen, setIsOpen] = useState(false)
|
|
36
40
|
const [search, setSearch] = useState('')
|
|
37
41
|
const [highlightIdx, setHighlightIdx] = useState(-1)
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
const searchRef = useRef<HTMLInputElement>(null)
|
|
42
|
+
const { triggerRef, menuRef, position } = useDropdownPortal(isOpen)
|
|
43
|
+
const searchRef = { current: null as HTMLInputElement | null }
|
|
41
44
|
const isActive = value !== 'all'
|
|
42
45
|
const showSearch = options.length > SEARCH_THRESHOLD
|
|
43
46
|
const v = VARIANT_CLASSES[variant]
|
|
44
47
|
|
|
45
|
-
useClickOutside(
|
|
48
|
+
useClickOutside([triggerRef, menuRef], isOpen, () => setIsOpen(false))
|
|
46
49
|
|
|
47
50
|
useEffect(() => {
|
|
48
51
|
if (!isOpen) { setSearch(''); setHighlightIdx(-1) }
|
|
@@ -85,89 +88,99 @@ export function FilterDropdown({
|
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
const menu = isOpen && createPortal(
|
|
92
|
+
<div
|
|
93
|
+
ref={menuRef}
|
|
94
|
+
role="listbox"
|
|
95
|
+
style={{ position: 'fixed', top: position.top, left: position.left, minWidth: position.minWidth, zIndex: 9999 }}
|
|
96
|
+
className={`whitespace-nowrap bg-[var(--popover)] border ${FORM_COLORS[effectiveColor].border} rounded-lg shadow-lg overflow-hidden`}
|
|
97
|
+
>
|
|
98
|
+
{showSearch && (
|
|
99
|
+
<div className={`sticky top-0 p-1.5 bg-[var(--popover)] border-b ${FORM_COLORS[effectiveColor].border} z-10`}>
|
|
100
|
+
<div className="relative">
|
|
101
|
+
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-neutral-500" />
|
|
102
|
+
<input
|
|
103
|
+
ref={(el) => { searchRef.current = el }}
|
|
104
|
+
type="text"
|
|
105
|
+
value={search}
|
|
106
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
107
|
+
onKeyDown={handleKeyDown}
|
|
108
|
+
placeholder="Search..."
|
|
109
|
+
className={`w-full pl-6 pr-2 py-1 text-sm bg-[var(--popover)] border border-neutral-600 rounded text-neutral-200 placeholder-neutral-500 outline-none ${FORM_COLORS[effectiveColor].focus}`}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
{hasAllOption && (
|
|
115
|
+
<button
|
|
116
|
+
data-idx={0}
|
|
117
|
+
onClick={() => handleSelect('all')}
|
|
118
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors cursor-pointer ${
|
|
119
|
+
highlightIdx === 0
|
|
120
|
+
? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200`
|
|
121
|
+
: !isActive ? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
|
|
122
|
+
}`}
|
|
123
|
+
>
|
|
124
|
+
<Check className={`w-3 h-3 shrink-0 ${!isActive ? FORM_COLORS[effectiveColor].accent : 'invisible'}`} />
|
|
125
|
+
<span>All</span>
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
{filtered.map((opt, i) => {
|
|
129
|
+
const idx = i + (hasAllOption ? 1 : 0)
|
|
130
|
+
const isHighlighted = highlightIdx === idx
|
|
131
|
+
const isSelected = value === opt.value
|
|
132
|
+
return (
|
|
133
|
+
<button
|
|
134
|
+
key={opt.value}
|
|
135
|
+
data-idx={idx}
|
|
136
|
+
onClick={() => handleSelect(opt.value)}
|
|
137
|
+
className={`w-full flex items-center gap-2 px-3 py-1.5 text-sm text-left transition-colors cursor-pointer ${
|
|
138
|
+
isHighlighted
|
|
139
|
+
? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200`
|
|
140
|
+
: isSelected ? `${FORM_COLORS[effectiveColor].selectedBg} text-neutral-200` : `text-neutral-400 ${v.hoverBg}`
|
|
141
|
+
}`}
|
|
142
|
+
>
|
|
143
|
+
<Check className={`w-3 h-3 shrink-0 ${isSelected ? FORM_COLORS[effectiveColor].accent : 'invisible'}`} />
|
|
144
|
+
<span>{opt.label}</span>
|
|
145
|
+
</button>
|
|
146
|
+
)
|
|
147
|
+
})}
|
|
148
|
+
{showSearch && search && filtered.length === 0 && (
|
|
149
|
+
<div className="px-3 py-2 text-sm text-neutral-500">No matches</div>
|
|
150
|
+
)}
|
|
151
|
+
</div>,
|
|
152
|
+
document.body,
|
|
153
|
+
)
|
|
154
|
+
|
|
88
155
|
return (
|
|
89
|
-
<div className="relative flex items-center" ref={
|
|
156
|
+
<div className="relative flex items-center" ref={triggerRef} onKeyDown={handleKeyDown}>
|
|
90
157
|
<button
|
|
158
|
+
aria-expanded={isOpen}
|
|
159
|
+
aria-haspopup="listbox"
|
|
91
160
|
onClick={() => setIsOpen(!isOpen)}
|
|
92
|
-
className={`flex items-center gap-1.5
|
|
161
|
+
className={`flex items-center gap-1.5 py-1 px-2 rounded-md border ${v.bg} text-sm transition-colors cursor-pointer ${
|
|
93
162
|
isActive
|
|
94
|
-
? `${clearable ? 'rounded-r-none border-r-0' : ''} ${FORM_COLORS[
|
|
163
|
+
? `${clearable ? 'rounded-r-none border-r-0' : ''} ${FORM_COLORS[effectiveColor].border} text-neutral-200 ${FORM_COLORS[effectiveColor].hover}`
|
|
95
164
|
: isOpen
|
|
96
|
-
? `${FORM_COLORS[
|
|
97
|
-
: `${FORM_COLORS[
|
|
165
|
+
? `${FORM_COLORS[effectiveColor].border} text-neutral-200`
|
|
166
|
+
: `${FORM_COLORS[effectiveColor].border} text-neutral-400 ${FORM_COLORS[effectiveColor].hover} hover:text-neutral-200`
|
|
98
167
|
}`}
|
|
99
168
|
>
|
|
100
|
-
<Filter className={`w-3 h-3 ${isActive ? FORM_COLORS[
|
|
169
|
+
<Filter className={`w-3 h-3 ${isActive ? FORM_COLORS[effectiveColor].accent : ''}`} />
|
|
101
170
|
{labelExtra}
|
|
102
|
-
<span className="
|
|
171
|
+
<span className="truncate">{selectedLabel}</span>
|
|
103
172
|
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
104
173
|
</button>
|
|
105
174
|
{isActive && clearable && (
|
|
106
175
|
<button
|
|
176
|
+
aria-label="Clear filter"
|
|
107
177
|
onClick={() => onChange('all')}
|
|
108
|
-
className={`flex items-center justify-center
|
|
178
|
+
className={`flex items-center justify-center py-1 px-1.5 rounded-r-md border border-l-0 ${FORM_COLORS[effectiveColor].border} ${v.bg} text-neutral-400 ${FORM_COLORS[effectiveColor].hover} hover:text-neutral-200 transition-colors cursor-pointer`}
|
|
109
179
|
>
|
|
110
180
|
<X className="w-3 h-3" />
|
|
111
181
|
</button>
|
|
112
182
|
)}
|
|
113
|
-
|
|
114
|
-
{isOpen && (
|
|
115
|
-
<div ref={menuRef} className={`absolute right-0 top-full z-50 mt-1 min-w-[140px] whitespace-nowrap bg-[var(--popover)] border ${FORM_COLORS[color].border} rounded-lg shadow-xl overflow-hidden`}>
|
|
116
|
-
{showSearch && (
|
|
117
|
-
<div className={`sticky top-0 p-1.5 bg-[var(--popover)] 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-sm bg-[var(--popover)] 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-sm text-left transition-colors cursor-pointer ${
|
|
137
|
-
highlightIdx === 0
|
|
138
|
-
? `${FORM_COLORS[color].selectedBg} 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-sm text-left transition-colors cursor-pointer ${
|
|
156
|
-
isHighlighted
|
|
157
|
-
? `${FORM_COLORS[color].selectedBg} 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-sm text-neutral-500">No matches</div>
|
|
168
|
-
)}
|
|
169
|
-
</div>
|
|
170
|
-
)}
|
|
183
|
+
{menu}
|
|
171
184
|
</div>
|
|
172
185
|
)
|
|
173
186
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { IconButton, type IconName, type IconButtonProps, type IconButtonStatus } from './icon-button.tsx'
|
|
2
|
+
import { AccentColorProvider, useAccentColor } from '../lib/accent-context.ts'
|
|
3
|
+
import type { FormColor } from '../lib/form-colors.ts'
|
|
4
|
+
import { cn } from '../lib/cn.ts'
|
|
2
5
|
|
|
3
6
|
export interface FormActionsProps {
|
|
4
7
|
/** Cancel handler — renders X button. Optional (e.g. AlertModal has no cancel). */
|
|
@@ -17,7 +20,7 @@ export interface FormActionsProps {
|
|
|
17
20
|
onConfirm?: () => void
|
|
18
21
|
confirmTooltip?: string
|
|
19
22
|
confirmIcon?: IconName
|
|
20
|
-
confirmColor?: IconButtonProps['
|
|
23
|
+
confirmColor?: IconButtonProps['accentColor']
|
|
21
24
|
confirmDisabled?: boolean
|
|
22
25
|
confirmStatus?: IconButtonStatus
|
|
23
26
|
|
|
@@ -30,6 +33,7 @@ export interface FormActionsProps {
|
|
|
30
33
|
|
|
31
34
|
border?: boolean
|
|
32
35
|
padding?: 'compact' | 'normal' | 'modal'
|
|
36
|
+
accentColor?: FormColor
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
const PADDING_CLASSES = {
|
|
@@ -64,22 +68,27 @@ export function FormActions({
|
|
|
64
68
|
statusText,
|
|
65
69
|
border,
|
|
66
70
|
padding = 'normal',
|
|
71
|
+
accentColor,
|
|
67
72
|
}: FormActionsProps) {
|
|
73
|
+
const contextAccent = useAccentColor()
|
|
74
|
+
const effectiveColor = accentColor ?? contextAccent ?? 'blue'
|
|
68
75
|
const showBorder = border ?? DEFAULT_BORDER[padding]
|
|
69
|
-
const paddingClass = showBorder
|
|
70
|
-
? `${PADDING_CLASSES[padding]} ${BORDER_CLASS}`
|
|
71
|
-
: PADDING_CLASSES[padding]
|
|
72
|
-
|
|
73
76
|
const hasLeft = onBack || statusText
|
|
74
77
|
|
|
75
78
|
return (
|
|
76
|
-
<
|
|
79
|
+
<AccentColorProvider value={effectiveColor}>
|
|
80
|
+
<div className={cn(
|
|
81
|
+
'flex items-center gap-2',
|
|
82
|
+
hasLeft ? 'justify-between' : 'justify-end',
|
|
83
|
+
PADDING_CLASSES[padding],
|
|
84
|
+
showBorder && BORDER_CLASS,
|
|
85
|
+
)}>
|
|
77
86
|
{hasLeft && (
|
|
78
87
|
<div className="flex items-center gap-2">
|
|
79
88
|
{onBack && (
|
|
80
89
|
<IconButton
|
|
81
90
|
icon="arrow-left"
|
|
82
|
-
|
|
91
|
+
accentColor="neutral"
|
|
83
92
|
onClick={onBack}
|
|
84
93
|
tooltip={{ description: backTooltip }}
|
|
85
94
|
/>
|
|
@@ -91,7 +100,7 @@ export function FormActions({
|
|
|
91
100
|
{onMinimize && (
|
|
92
101
|
<IconButton
|
|
93
102
|
icon="minimize"
|
|
94
|
-
|
|
103
|
+
accentColor="neutral"
|
|
95
104
|
onClick={onMinimize}
|
|
96
105
|
tooltip={{ description: minimizeTooltip }}
|
|
97
106
|
/>
|
|
@@ -99,7 +108,7 @@ export function FormActions({
|
|
|
99
108
|
{onCancel && (
|
|
100
109
|
<IconButton
|
|
101
110
|
icon="x"
|
|
102
|
-
|
|
111
|
+
accentColor="neutral"
|
|
103
112
|
onClick={onCancel}
|
|
104
113
|
tooltip={{ description: cancelTooltip }}
|
|
105
114
|
/>
|
|
@@ -107,7 +116,7 @@ export function FormActions({
|
|
|
107
116
|
{onConfirm && (
|
|
108
117
|
<IconButton
|
|
109
118
|
icon={confirmIcon}
|
|
110
|
-
|
|
119
|
+
accentColor={confirmColor}
|
|
111
120
|
onClick={onConfirm}
|
|
112
121
|
disabled={confirmDisabled}
|
|
113
122
|
status={confirmStatus}
|
|
@@ -117,12 +126,13 @@ export function FormActions({
|
|
|
117
126
|
{onNext && (
|
|
118
127
|
<IconButton
|
|
119
128
|
icon="arrow-right"
|
|
120
|
-
|
|
129
|
+
accentColor="neutral"
|
|
121
130
|
onClick={onNext}
|
|
122
131
|
tooltip={{ description: nextTooltip }}
|
|
123
132
|
/>
|
|
124
133
|
)}
|
|
125
134
|
</div>
|
|
126
135
|
</div>
|
|
136
|
+
</AccentColorProvider>
|
|
127
137
|
)
|
|
128
138
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ReactNode } from 'react'
|
|
2
2
|
import { ChevronRight } from 'lucide-react'
|
|
3
3
|
import { Checkbox } from './checkbox.tsx'
|
|
4
|
+
import { AccentColorProvider, useAccentColor } from '../lib/accent-context.ts'
|
|
5
|
+
import type { FormColor } from '../lib/form-colors.ts'
|
|
4
6
|
|
|
5
7
|
export interface FrontmatterFormHeaderProps {
|
|
6
8
|
collapsed: boolean
|
|
@@ -13,6 +15,7 @@ export interface FrontmatterFormHeaderProps {
|
|
|
13
15
|
/** Toggle frontmatter on/off */
|
|
14
16
|
onFrontmatterToggle?: (enabled: boolean) => void
|
|
15
17
|
readOnly?: boolean
|
|
18
|
+
accentColor?: FormColor
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export function FrontmatterFormHeader({
|
|
@@ -23,16 +26,20 @@ export function FrontmatterFormHeader({
|
|
|
23
26
|
frontmatterEnabled,
|
|
24
27
|
onFrontmatterToggle,
|
|
25
28
|
readOnly,
|
|
29
|
+
accentColor,
|
|
26
30
|
}: FrontmatterFormHeaderProps) {
|
|
31
|
+
const contextAccent = useAccentColor()
|
|
32
|
+
const effectiveColor = accentColor ?? contextAccent ?? 'blue'
|
|
27
33
|
const hasFm = frontmatterEnabled !== false
|
|
28
34
|
|
|
29
35
|
return (
|
|
30
|
-
<
|
|
36
|
+
<AccentColorProvider value={effectiveColor}>
|
|
37
|
+
<div className="bg-neutral-980 border-b border-neutral-960 select-none">
|
|
31
38
|
{/* Header bar — always visible, always expandable */}
|
|
32
39
|
<button
|
|
33
40
|
type="button"
|
|
34
41
|
onClick={onToggle}
|
|
35
|
-
className="flex items-center gap-2 w-full px-3 py-3 hover:bg-neutral-
|
|
42
|
+
className="flex items-center gap-2 w-full px-3 py-3 hover:bg-neutral-960/50 cursor-pointer transition-colors"
|
|
36
43
|
>
|
|
37
44
|
<ChevronRight
|
|
38
45
|
className={`w-3.5 h-3.5 text-neutral-500 transition-transform duration-150 ${
|
|
@@ -76,5 +83,6 @@ export function FrontmatterFormHeader({
|
|
|
76
83
|
</div>
|
|
77
84
|
)}
|
|
78
85
|
</div>
|
|
86
|
+
</AccentColorProvider>
|
|
79
87
|
)
|
|
80
88
|
}
|