@toolr/ui-design 0.1.6 → 0.1.8
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/components/hooks/use-click-outside.ts +10 -3
- package/components/hooks/use-modal-behavior.ts +53 -0
- package/components/hooks/use-navigation-history.ts +7 -2
- package/components/hooks/use-resizable-sidebar.ts +38 -0
- package/components/lib/form-colors.ts +40 -0
- package/components/sections/captured-issues/captured-issues-panel.tsx +3 -3
- package/components/sections/captured-issues/use-captured-issues.ts +9 -3
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +1 -1
- package/components/sections/golden-snapshots/status-overview.tsx +1 -1
- package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +4 -40
- package/components/sections/prompt-editor/index.ts +0 -7
- package/components/sections/prompt-editor/simulator-prompt-editor.tsx +4 -40
- package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +4 -36
- package/components/sections/snippets-editor/snippets-editor.tsx +6 -39
- package/components/settings/SettingsHeader.tsx +0 -1
- package/components/settings/SettingsTreeNav.tsx +9 -12
- package/components/ui/action-dialog.tsx +19 -55
- package/components/ui/ai-action-button.tsx +2 -4
- package/components/ui/badge.tsx +15 -23
- package/components/ui/breadcrumb.tsx +11 -71
- package/components/ui/checkbox.tsx +19 -27
- package/components/ui/collapsible-section.tsx +4 -41
- package/components/ui/confirm-badge.tsx +14 -23
- package/components/ui/cookie-consent.tsx +18 -2
- package/components/ui/debounce-border-overlay.tsx +31 -0
- package/components/ui/detail-section.tsx +2 -19
- package/components/ui/editor-placeholder-card.tsx +10 -9
- package/components/ui/execution-details-panel.tsx +2 -7
- package/components/ui/extension-list-card.tsx +1 -1
- package/components/ui/file-structure-section.tsx +3 -18
- package/components/ui/file-tree.tsx +6 -18
- package/components/ui/files-panel.tsx +3 -11
- package/components/ui/filter-dropdown.tsx +5 -2
- package/components/ui/form-actions.tsx +11 -8
- package/components/ui/icon-button.tsx +7 -6
- package/components/ui/input.tsx +18 -29
- package/components/ui/label.tsx +7 -17
- package/components/ui/layout-tab-bar.tsx +5 -5
- package/components/ui/modal.tsx +10 -18
- package/components/ui/nav-card.tsx +3 -18
- package/components/ui/navigation-bar.tsx +12 -73
- package/components/ui/number-input.tsx +6 -0
- package/components/ui/registry-browser.tsx +6 -20
- package/components/ui/registry-card.tsx +3 -7
- package/components/ui/resizable-textarea.tsx +13 -35
- package/components/ui/segmented-toggle.tsx +4 -1
- package/components/ui/select.tsx +8 -14
- package/components/ui/selection-grid.tsx +6 -50
- package/components/ui/setting-row.tsx +5 -5
- package/components/ui/settings-card.tsx +2 -2
- package/components/ui/settings-info-box.tsx +6 -24
- package/components/ui/sort-dropdown.tsx +8 -5
- package/components/ui/status-card.tsx +2 -13
- package/components/ui/tab-bar.tsx +17 -33
- package/components/ui/toggle.tsx +22 -30
- package/components/ui/tooltip.tsx +11 -23
- package/dist/index.d.ts +71 -142
- package/dist/index.js +1630 -2436
- package/index.ts +8 -7
- package/package.json +9 -1
- package/components/sections/prompt-editor/use-prompt-editor.ts +0 -131
|
@@ -60,7 +60,8 @@ export function EditorPlaceholderCard({
|
|
|
60
60
|
className = '',
|
|
61
61
|
}: EditorPlaceholderCardProps) {
|
|
62
62
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
63
|
-
const [
|
|
63
|
+
const [isPlaceholderCopied, setIsPlaceholderCopied] = useState(false)
|
|
64
|
+
const [isValueCopied, setIsValueCopied] = useState(false)
|
|
64
65
|
const [isOverflowing, setIsOverflowing] = useState(false)
|
|
65
66
|
const valueRef = useRef<HTMLDivElement>(null)
|
|
66
67
|
|
|
@@ -78,8 +79,8 @@ export function EditorPlaceholderCard({
|
|
|
78
79
|
const handleCopyPlaceholder = async () => {
|
|
79
80
|
try {
|
|
80
81
|
await navigator.clipboard.writeText(`{{${name}}}`)
|
|
81
|
-
|
|
82
|
-
setTimeout(() =>
|
|
82
|
+
setIsPlaceholderCopied(true)
|
|
83
|
+
setTimeout(() => setIsPlaceholderCopied(false), 1500)
|
|
83
84
|
} catch {
|
|
84
85
|
// Clipboard API not available
|
|
85
86
|
}
|
|
@@ -89,8 +90,8 @@ export function EditorPlaceholderCard({
|
|
|
89
90
|
if (!value) return
|
|
90
91
|
try {
|
|
91
92
|
await navigator.clipboard.writeText(value)
|
|
92
|
-
|
|
93
|
-
setTimeout(() =>
|
|
93
|
+
setIsValueCopied(true)
|
|
94
|
+
setTimeout(() => setIsValueCopied(false), 1500)
|
|
94
95
|
} catch {
|
|
95
96
|
// Clipboard API not available
|
|
96
97
|
}
|
|
@@ -119,20 +120,20 @@ export function EditorPlaceholderCard({
|
|
|
119
120
|
<div className="flex items-center gap-1 shrink-0">
|
|
120
121
|
{showCopyPlaceholder && (
|
|
121
122
|
<IconButton
|
|
122
|
-
icon={
|
|
123
|
+
icon={isPlaceholderCopied ? 'check' : 'copy'}
|
|
123
124
|
onClick={handleCopyPlaceholder}
|
|
124
125
|
size="sm"
|
|
125
|
-
color={
|
|
126
|
+
color={isPlaceholderCopied ? 'green' : 'neutral'}
|
|
126
127
|
tooltip={{ description: `Copy {{${name}}}` }}
|
|
127
128
|
tooltipPosition="left"
|
|
128
129
|
/>
|
|
129
130
|
)}
|
|
130
131
|
{showCopyValue && hasValue && (
|
|
131
132
|
<IconButton
|
|
132
|
-
icon={
|
|
133
|
+
icon={isValueCopied ? 'check' : 'copy'}
|
|
133
134
|
onClick={handleCopyValue}
|
|
134
135
|
size="sm"
|
|
135
|
-
color={
|
|
136
|
+
color={isValueCopied ? 'green' : 'neutral'}
|
|
136
137
|
tooltip={{ description: 'Copy value to clipboard' }}
|
|
137
138
|
tooltipPosition="left"
|
|
138
139
|
/>
|
|
@@ -9,15 +9,10 @@
|
|
|
9
9
|
import { AlertTriangle, Info } from 'lucide-react'
|
|
10
10
|
import { Checkbox } from './checkbox.tsx'
|
|
11
11
|
import { cn } from '../lib/cn.ts'
|
|
12
|
-
|
|
13
|
-
export interface ExecutionDetailRow {
|
|
14
|
-
label: string
|
|
15
|
-
value: string
|
|
16
|
-
mono?: boolean
|
|
17
|
-
}
|
|
12
|
+
import type { DetailRow } from './detail-section.tsx'
|
|
18
13
|
|
|
19
14
|
export interface ExecutionDetailsPanelProps {
|
|
20
|
-
details:
|
|
15
|
+
details: DetailRow[]
|
|
21
16
|
/** Show the "Allow direct edits" toggle */
|
|
22
17
|
allowDirectEdits?: boolean
|
|
23
18
|
onAllowDirectEditsChange?: (value: boolean) => void
|
|
@@ -81,7 +81,7 @@ export const ExtensionListCard = memo(function ExtensionListCard({
|
|
|
81
81
|
<Icon className={cn('w-5 h-5 shrink-0', iconColor)} />
|
|
82
82
|
<div className="min-w-0 flex-1">
|
|
83
83
|
<div className="flex items-center gap-2 flex-wrap">
|
|
84
|
-
<span className={cn('text-md font-medium', titleClassName)}>{title}</span>
|
|
84
|
+
<span className={cn('text-md font-medium truncate', titleClassName)}>{title}</span>
|
|
85
85
|
{badges}
|
|
86
86
|
</div>
|
|
87
87
|
{description && (
|
|
@@ -1,19 +1,11 @@
|
|
|
1
1
|
import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from 'react'
|
|
2
2
|
import { FileCode, FolderTree, Loader2, AlertCircle, AlignLeft, Code2, Type } from 'lucide-react'
|
|
3
|
-
import {
|
|
3
|
+
import { type AccentColor, ACCENT_ICON } from '../lib/form-colors.ts'
|
|
4
|
+
import { CollapseButton } from './icon-button.tsx'
|
|
4
5
|
import { SegmentedToggle } from './segmented-toggle.tsx'
|
|
5
6
|
import { FileTree, collectDirPaths, type FileTreeNode } from './file-tree.tsx'
|
|
6
7
|
|
|
7
8
|
export type PreviewMode = 'format' | 'language' | 'plain'
|
|
8
|
-
export type AccentColor = 'blue' | 'green' | 'red' | 'orange' | 'cyan' | 'yellow' | 'purple' | 'indigo' | 'emerald' | 'amber' | 'violet' | 'neutral' | 'sky' | 'pink' | 'teal'
|
|
9
|
-
|
|
10
|
-
const ACCENT_ICON: Record<AccentColor, string> = {
|
|
11
|
-
blue: 'text-blue-400', green: 'text-green-400', red: 'text-red-400',
|
|
12
|
-
orange: 'text-orange-400', cyan: 'text-cyan-400', yellow: 'text-yellow-400',
|
|
13
|
-
purple: 'text-purple-400', indigo: 'text-indigo-400', emerald: 'text-emerald-400',
|
|
14
|
-
amber: 'text-amber-400', violet: 'text-violet-400', neutral: 'text-neutral-400',
|
|
15
|
-
sky: 'text-sky-400', pink: 'text-pink-400', teal: 'text-teal-400',
|
|
16
|
-
}
|
|
17
9
|
|
|
18
10
|
const ACCENT_BORDER: Record<AccentColor, string> = {
|
|
19
11
|
blue: 'border-blue-500/25', green: 'border-green-500/25', red: 'border-red-500/25',
|
|
@@ -23,13 +15,6 @@ const ACCENT_BORDER: Record<AccentColor, string> = {
|
|
|
23
15
|
sky: 'border-sky-500/25', pink: 'border-pink-500/25', teal: 'border-teal-500/25',
|
|
24
16
|
}
|
|
25
17
|
|
|
26
|
-
const ACCENT_BUTTON: Record<AccentColor, IconButtonColor> = {
|
|
27
|
-
blue: 'blue', green: 'green', red: 'red',
|
|
28
|
-
orange: 'orange', cyan: 'cyan', yellow: 'yellow',
|
|
29
|
-
purple: 'purple', indigo: 'indigo', emerald: 'emerald',
|
|
30
|
-
amber: 'amber', violet: 'violet', neutral: 'neutral',
|
|
31
|
-
sky: 'sky', pink: 'pink', teal: 'teal',
|
|
32
|
-
}
|
|
33
18
|
|
|
34
19
|
const ACCENT_HANDLE: Record<AccentColor, string> = {
|
|
35
20
|
blue: 'bg-blue-500/30 group-hover:bg-blue-400/50', green: 'bg-green-500/30 group-hover:bg-green-400/50', red: 'bg-red-500/30 group-hover:bg-red-400/50',
|
|
@@ -350,7 +335,7 @@ export function FileStructureSection({
|
|
|
350
335
|
<CollapseButton
|
|
351
336
|
collapsed={allCollapsed}
|
|
352
337
|
onToggle={() => setExpandedPaths(allCollapsed ? new Set(allDirPaths) : new Set())}
|
|
353
|
-
color={
|
|
338
|
+
color={accentColor}
|
|
354
339
|
/>
|
|
355
340
|
</div>
|
|
356
341
|
<div className={`${variant === 'split' ? 'flex-1 overflow-y-auto' : ''} p-3`}>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FileCode, Folder, FolderOpen, ChevronRight, ChevronDown } from 'lucide-react'
|
|
2
|
+
import { ACCENT_ICON, type AccentColor } from '../lib/form-colors.ts'
|
|
2
3
|
|
|
3
4
|
export interface FileTreeNode {
|
|
4
5
|
name: string
|
|
@@ -30,19 +31,6 @@ const ACCENT_SELECTED: Record<string, string> = {
|
|
|
30
31
|
violet: 'bg-violet-400/20 text-neutral-200',
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
const ACCENT_ICON: Record<string, string> = {
|
|
34
|
-
blue: 'text-blue-400',
|
|
35
|
-
purple: 'text-purple-400',
|
|
36
|
-
orange: 'text-orange-400',
|
|
37
|
-
green: 'text-green-400',
|
|
38
|
-
pink: 'text-pink-400',
|
|
39
|
-
amber: 'text-amber-400',
|
|
40
|
-
emerald: 'text-emerald-400',
|
|
41
|
-
teal: 'text-teal-400',
|
|
42
|
-
sky: 'text-sky-400',
|
|
43
|
-
violet: 'text-violet-400',
|
|
44
|
-
}
|
|
45
|
-
|
|
46
34
|
function nodeHasFiles(node: FileTreeNode): boolean {
|
|
47
35
|
if (node.type === 'file') return true
|
|
48
36
|
return !!node.children?.some(nodeHasFiles)
|
|
@@ -73,7 +61,7 @@ export function FileTree({ nodes, rootName, selectedPath, onSelectFile, prefix =
|
|
|
73
61
|
if (rootName) {
|
|
74
62
|
const rootNode: FileTreeNode = { name: rootName, type: 'directory', children: nodes }
|
|
75
63
|
return (
|
|
76
|
-
<ul className="space-y-0.5">
|
|
64
|
+
<ul role="tree" className="space-y-0.5">
|
|
77
65
|
<FileTreeNodeItem
|
|
78
66
|
node={rootNode}
|
|
79
67
|
path={rootName}
|
|
@@ -88,7 +76,7 @@ export function FileTree({ nodes, rootName, selectedPath, onSelectFile, prefix =
|
|
|
88
76
|
}
|
|
89
77
|
|
|
90
78
|
return (
|
|
91
|
-
<ul className="space-y-0.5">
|
|
79
|
+
<ul role="tree" className="space-y-0.5">
|
|
92
80
|
{nodes.filter(nodeHasFiles).map((node) => {
|
|
93
81
|
const fullPath = prefix ? `${prefix}/${node.name}` : node.name
|
|
94
82
|
return (
|
|
@@ -124,7 +112,7 @@ function FileTreeNodeItem({ node, path, selectedPath, onSelectFile, expandedPath
|
|
|
124
112
|
const expanded = isDir && expandedPaths.has(path)
|
|
125
113
|
const base = 'flex items-center gap-1.5 py-0.5 px-1 rounded text-sm transition-colors overflow-hidden whitespace-nowrap'
|
|
126
114
|
const selectedClass = ACCENT_SELECTED[accentColor] ?? ACCENT_SELECTED.blue
|
|
127
|
-
const iconColorClass = ACCENT_ICON[accentColor] ?? ACCENT_ICON.blue
|
|
115
|
+
const iconColorClass = ACCENT_ICON[accentColor as AccentColor] ?? ACCENT_ICON.blue
|
|
128
116
|
const rowClass = isSelected
|
|
129
117
|
? `${base} ${selectedClass}`
|
|
130
118
|
: isDir
|
|
@@ -132,7 +120,7 @@ function FileTreeNodeItem({ node, path, selectedPath, onSelectFile, expandedPath
|
|
|
132
120
|
: `${base} cursor-pointer text-white hover:bg-neutral-700/50 hover:text-neutral-200`
|
|
133
121
|
|
|
134
122
|
return (
|
|
135
|
-
<li>
|
|
123
|
+
<li role="treeitem" aria-expanded={isDir ? expanded : undefined} aria-selected={isSelected}>
|
|
136
124
|
<button
|
|
137
125
|
onClick={isDir ? () => onTogglePath(path) : () => onSelectFile(path)}
|
|
138
126
|
className={rowClass}
|
|
@@ -150,7 +138,7 @@ function FileTreeNodeItem({ node, path, selectedPath, onSelectFile, expandedPath
|
|
|
150
138
|
<span className="truncate">{node.name}</span>
|
|
151
139
|
</button>
|
|
152
140
|
{isDir && expanded && node.children && (
|
|
153
|
-
<ul className="ml-4 space-y-0.5">
|
|
141
|
+
<ul role="group" className="ml-4 space-y-0.5">
|
|
154
142
|
{node.children.filter(nodeHasFiles).map((child) => {
|
|
155
143
|
const childPath = `${path}/${child.name}`
|
|
156
144
|
return (
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useState, useMemo } 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
|
-
import type
|
|
6
|
+
import { iconMap, type IconName } from './icon-button.tsx'
|
|
7
7
|
import { cn } from '../lib/cn.ts'
|
|
8
8
|
|
|
9
9
|
const ACCENT_SELECTED: Record<string, string> = {
|
|
@@ -19,14 +19,6 @@ const ACCENT_SELECTED: Record<string, string> = {
|
|
|
19
19
|
violet: 'bg-violet-400/15 text-violet-400',
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
const iconSubset: Partial<Record<IconName, LucideIcon>> = {
|
|
23
|
-
folder: Folder,
|
|
24
|
-
file: File,
|
|
25
|
-
code: FileCode,
|
|
26
|
-
image: Image,
|
|
27
|
-
search: Search,
|
|
28
|
-
}
|
|
29
|
-
|
|
30
22
|
const EXTENSION_ICON_MAP: Record<string, LucideIcon> = {
|
|
31
23
|
ts: FileCode,
|
|
32
24
|
tsx: FileCode,
|
|
@@ -88,7 +80,7 @@ function countFiles(entries: FileEntry[]): number {
|
|
|
88
80
|
}
|
|
89
81
|
|
|
90
82
|
function getFileIcon(entry: FileEntry): LucideIcon {
|
|
91
|
-
if (entry.icon &&
|
|
83
|
+
if (entry.icon && iconMap[entry.icon]) return iconMap[entry.icon]!
|
|
92
84
|
if (entry.type === 'folder') return Folder
|
|
93
85
|
const ext = entry.name.split('.').pop()?.toLowerCase()
|
|
94
86
|
if (ext && EXTENSION_ICON_MAP[ext]) return EXTENSION_ICON_MAP[ext]
|
|
@@ -96,7 +88,7 @@ function getFileIcon(entry: FileEntry): LucideIcon {
|
|
|
96
88
|
}
|
|
97
89
|
|
|
98
90
|
function getFolderIcon(expanded: boolean, entry: FileEntry): LucideIcon {
|
|
99
|
-
if (entry.icon &&
|
|
91
|
+
if (entry.icon && iconMap[entry.icon]) return iconMap[entry.icon]!
|
|
100
92
|
return expanded ? FolderOpen : Folder
|
|
101
93
|
}
|
|
102
94
|
|
|
@@ -88,6 +88,8 @@ export function FilterDropdown({
|
|
|
88
88
|
return (
|
|
89
89
|
<div className="relative flex items-center" ref={ref} onKeyDown={handleKeyDown}>
|
|
90
90
|
<button
|
|
91
|
+
aria-expanded={isOpen}
|
|
92
|
+
aria-haspopup="listbox"
|
|
91
93
|
onClick={() => setIsOpen(!isOpen)}
|
|
92
94
|
className={`flex items-center gap-1.5 h-7 px-2 rounded-md border ${v.bg} text-sm transition-colors cursor-pointer ${
|
|
93
95
|
isActive
|
|
@@ -99,11 +101,12 @@ export function FilterDropdown({
|
|
|
99
101
|
>
|
|
100
102
|
<Filter className={`w-3 h-3 ${isActive ? FORM_COLORS[color].accent : ''}`} />
|
|
101
103
|
{labelExtra}
|
|
102
|
-
<span className="
|
|
104
|
+
<span className="truncate">{selectedLabel}</span>
|
|
103
105
|
<ChevronDown className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
|
|
104
106
|
</button>
|
|
105
107
|
{isActive && clearable && (
|
|
106
108
|
<button
|
|
109
|
+
aria-label="Clear filter"
|
|
107
110
|
onClick={() => onChange('all')}
|
|
108
111
|
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
112
|
>
|
|
@@ -112,7 +115,7 @@ export function FilterDropdown({
|
|
|
112
115
|
)}
|
|
113
116
|
|
|
114
117
|
{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-
|
|
118
|
+
<div ref={menuRef} role="listbox" 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-lg overflow-hidden`}>
|
|
116
119
|
{showSearch && (
|
|
117
120
|
<div className={`sticky top-0 p-1.5 bg-[var(--popover)] border-b ${FORM_COLORS[color].border} z-10`}>
|
|
118
121
|
<div className="relative">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IconButton, type IconName, type IconButtonProps, type IconButtonStatus } from './icon-button.tsx'
|
|
2
|
+
import { cn } from '../lib/cn.ts'
|
|
2
3
|
|
|
3
4
|
export interface FormActionsProps {
|
|
4
5
|
/** Cancel handler — renders X button. Optional (e.g. AlertModal has no cancel). */
|
|
@@ -34,10 +35,12 @@ export interface FormActionsProps {
|
|
|
34
35
|
|
|
35
36
|
const PADDING_CLASSES = {
|
|
36
37
|
compact: 'pt-2',
|
|
37
|
-
normal: 'pt-2
|
|
38
|
-
modal: 'px-4 py-3
|
|
38
|
+
normal: 'pt-2',
|
|
39
|
+
modal: 'px-4 py-3',
|
|
39
40
|
} as const
|
|
40
41
|
|
|
42
|
+
const BORDER_CLASS = 'border-t border-neutral-700'
|
|
43
|
+
|
|
41
44
|
const DEFAULT_BORDER = {
|
|
42
45
|
compact: false,
|
|
43
46
|
normal: true,
|
|
@@ -64,15 +67,15 @@ export function FormActions({
|
|
|
64
67
|
padding = 'normal',
|
|
65
68
|
}: FormActionsProps) {
|
|
66
69
|
const showBorder = border ?? DEFAULT_BORDER[padding]
|
|
67
|
-
const base = PADDING_CLASSES[padding]
|
|
68
|
-
const paddingClass = showBorder
|
|
69
|
-
? base
|
|
70
|
-
: base.replace(/\s*border-t\s+border-neutral-700/g, '')
|
|
71
|
-
|
|
72
70
|
const hasLeft = onBack || statusText
|
|
73
71
|
|
|
74
72
|
return (
|
|
75
|
-
<div className={
|
|
73
|
+
<div className={cn(
|
|
74
|
+
'flex items-center gap-2',
|
|
75
|
+
hasLeft ? 'justify-between' : 'justify-end',
|
|
76
|
+
PADDING_CLASSES[padding],
|
|
77
|
+
showBorder && BORDER_CLASS,
|
|
78
|
+
)}>
|
|
76
79
|
{hasLeft && (
|
|
77
80
|
<div className="flex items-center gap-2">
|
|
78
81
|
{onBack && (
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
AlertCircle, FileCode, Gauge, Home, PieChart, Settings2,
|
|
40
40
|
} from 'lucide-react'
|
|
41
41
|
import type { LucideIcon } from 'lucide-react'
|
|
42
|
+
import { type AccentColor } from '../lib/form-colors.ts'
|
|
42
43
|
import { Tooltip, type TooltipContent } from './tooltip.tsx'
|
|
43
44
|
|
|
44
45
|
export const iconMap = {
|
|
@@ -159,7 +160,7 @@ export interface ActionItem {
|
|
|
159
160
|
|
|
160
161
|
export type IconButtonStatus = 'loading' | 'success' | 'warning' | 'error'
|
|
161
162
|
|
|
162
|
-
export type IconButtonColor =
|
|
163
|
+
export type IconButtonColor = AccentColor
|
|
163
164
|
export type IconButtonVariant = 'filled' | 'outline'
|
|
164
165
|
|
|
165
166
|
export interface IconButtonProps {
|
|
@@ -259,9 +260,9 @@ const statusIcons: Record<IconButtonStatus, LucideIcon> = {
|
|
|
259
260
|
|
|
260
261
|
const statusConfig = {
|
|
261
262
|
loading: { color: undefined, active: true, animation: 'animate-spin' },
|
|
262
|
-
success: { color: 'green' as const, active: true, animation: '
|
|
263
|
-
warning: { color: 'amber' as const, active: true, animation: '
|
|
264
|
-
error: { color: 'red' as const, active: true, animation: '
|
|
263
|
+
success: { color: 'green' as const, active: true, animation: '' },
|
|
264
|
+
warning: { color: 'amber' as const, active: true, animation: '' },
|
|
265
|
+
error: { color: 'red' as const, active: true, animation: '' },
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
function resolveIcon(icon: IconName | ReactNode, status: IconButtonStatus | undefined): LucideIcon | null {
|
|
@@ -336,7 +337,7 @@ export function IconButton({
|
|
|
336
337
|
href={href}
|
|
337
338
|
target="_blank"
|
|
338
339
|
rel="noopener noreferrer"
|
|
339
|
-
aria-label={tooltip?.title}
|
|
340
|
+
aria-label={tooltip?.title || (typeof tooltip?.description === 'string' ? tooltip.description : undefined)}
|
|
340
341
|
data-testid={testId}
|
|
341
342
|
className={`${sharedClassName} cursor-pointer no-underline`}
|
|
342
343
|
>
|
|
@@ -347,7 +348,7 @@ export function IconButton({
|
|
|
347
348
|
type="button"
|
|
348
349
|
onClick={onClick}
|
|
349
350
|
disabled={disabled}
|
|
350
|
-
aria-label={tooltip?.title}
|
|
351
|
+
aria-label={tooltip?.title || (typeof tooltip?.description === 'string' ? tooltip.description : undefined)}
|
|
351
352
|
data-testid={testId}
|
|
352
353
|
className={`${sharedClassName} cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
|
|
353
354
|
>
|
package/components/ui/input.tsx
CHANGED
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
* - Extends native input attributes
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { forwardRef, useEffect, useRef, useState, type InputHTMLAttributes, type ReactNode } from 'react'
|
|
20
|
+
import { forwardRef, useEffect, useId, useRef, useState, type InputHTMLAttributes, type ReactNode } from 'react'
|
|
21
21
|
import { Search, X, Eye, EyeOff } from 'lucide-react'
|
|
22
|
+
import { DebounceBorderOverlay } from './debounce-border-overlay.tsx'
|
|
22
23
|
import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
|
|
23
24
|
|
|
24
25
|
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'size' | 'type'> {
|
|
@@ -50,6 +51,13 @@ const variantClasses = {
|
|
|
50
51
|
outline: 'bg-transparent',
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
const SEARCH_AUTO_PROPS = {
|
|
55
|
+
autoComplete: 'off' as const,
|
|
56
|
+
autoCorrect: 'off' as const,
|
|
57
|
+
autoCapitalize: 'off' as const,
|
|
58
|
+
spellCheck: false as const,
|
|
59
|
+
}
|
|
60
|
+
|
|
53
61
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
54
62
|
value,
|
|
55
63
|
onChange,
|
|
@@ -76,6 +84,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
76
84
|
const isSearch = type === 'search'
|
|
77
85
|
const isPassword = type === 'password'
|
|
78
86
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false)
|
|
87
|
+
const errorId = useId()
|
|
79
88
|
|
|
80
89
|
// Debounce state
|
|
81
90
|
const [internalValue, setInternalValue] = useState(value)
|
|
@@ -115,12 +124,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
115
124
|
return () => clearTimeout(timerRef.current)
|
|
116
125
|
}, [])
|
|
117
126
|
|
|
118
|
-
const searchAutoProps = isSearch ?
|
|
119
|
-
autoComplete: 'off' as const,
|
|
120
|
-
autoCorrect: 'off' as const,
|
|
121
|
-
autoCapitalize: 'off' as const,
|
|
122
|
-
spellCheck: false as const,
|
|
123
|
-
} : {}
|
|
127
|
+
const searchAutoProps = isSearch ? SEARCH_AUTO_PROPS : undefined
|
|
124
128
|
|
|
125
129
|
const showClear = isSearch && displayValue && !disabled
|
|
126
130
|
|
|
@@ -143,6 +147,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
143
147
|
onBlur={(e) => { setFocused(false); onBlurProp?.(e) }}
|
|
144
148
|
disabled={disabled}
|
|
145
149
|
data-testid={testId}
|
|
150
|
+
aria-invalid={hasError || undefined}
|
|
151
|
+
aria-describedby={typeof error === 'string' && error ? errorId : undefined}
|
|
152
|
+
{...(isSearch && !props['aria-label'] ? { 'aria-label': props.placeholder || 'Search' } : {})}
|
|
146
153
|
{...searchAutoProps}
|
|
147
154
|
className={`
|
|
148
155
|
w-full border rounded-lg text-neutral-200 placeholder-neutral-500
|
|
@@ -159,6 +166,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
159
166
|
{showClear && (
|
|
160
167
|
<button
|
|
161
168
|
type="button"
|
|
169
|
+
aria-label="Clear search"
|
|
162
170
|
onClick={handleClear}
|
|
163
171
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors z-10 cursor-pointer"
|
|
164
172
|
>
|
|
@@ -174,37 +182,18 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
|
174
182
|
<button
|
|
175
183
|
type="button"
|
|
176
184
|
onClick={() => setIsPasswordVisible(!isPasswordVisible)}
|
|
177
|
-
|
|
185
|
+
aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
|
|
178
186
|
className="absolute right-2 top-1/2 -translate-y-1/2 w-[18px] h-[18px] flex items-center justify-center rounded-md text-neutral-400 hover:text-neutral-300 hover:bg-neutral-500/20 transition-colors z-10 cursor-pointer"
|
|
179
187
|
>
|
|
180
188
|
{isPasswordVisible ? <EyeOff className="w-2.5 h-2.5" /> : <Eye className="w-2.5 h-2.5" />}
|
|
181
189
|
</button>
|
|
182
190
|
)}
|
|
183
191
|
{debounceMs > 0 && debounceKey > 0 && (
|
|
184
|
-
<
|
|
185
|
-
key={debounceKey}
|
|
186
|
-
className="absolute inset-0 pointer-events-none text-emerald-400/70"
|
|
187
|
-
style={{ width: '100%', height: '100%' }}
|
|
188
|
-
>
|
|
189
|
-
<rect
|
|
190
|
-
x="1" y="1" rx="5" ry="5"
|
|
191
|
-
fill="none"
|
|
192
|
-
stroke="currentColor"
|
|
193
|
-
strokeWidth="1.5"
|
|
194
|
-
pathLength="100"
|
|
195
|
-
strokeDasharray="100"
|
|
196
|
-
strokeDashoffset="0"
|
|
197
|
-
style={{
|
|
198
|
-
width: 'calc(100% - 2px)',
|
|
199
|
-
height: 'calc(100% - 2px)',
|
|
200
|
-
animation: `debounce-border-drain ${debounceMs}ms linear forwards`,
|
|
201
|
-
}}
|
|
202
|
-
/>
|
|
203
|
-
</svg>
|
|
192
|
+
<DebounceBorderOverlay debounceKey={debounceKey} durationMs={debounceMs} />
|
|
204
193
|
)}
|
|
205
194
|
</div>
|
|
206
195
|
{typeof error === 'string' && error && (
|
|
207
|
-
<p className="text-sm text-red-400 mt-1 text-right">{error}</p>
|
|
196
|
+
<p id={errorId} className="text-sm text-red-400 mt-1 text-right" role="alert">{error}</p>
|
|
208
197
|
)}
|
|
209
198
|
</div>
|
|
210
199
|
)
|
package/components/ui/label.tsx
CHANGED
|
@@ -12,25 +12,11 @@
|
|
|
12
12
|
* - Clickable with hover state
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { type AccentColor } from '../lib/form-colors.ts'
|
|
15
16
|
import { iconMap, type IconName } from './icon-button.tsx'
|
|
16
17
|
import { Tooltip } from './tooltip.tsx'
|
|
17
18
|
|
|
18
|
-
export type LabelColor =
|
|
19
|
-
| 'neutral'
|
|
20
|
-
| 'green'
|
|
21
|
-
| 'red'
|
|
22
|
-
| 'blue'
|
|
23
|
-
| 'yellow'
|
|
24
|
-
| 'orange'
|
|
25
|
-
| 'purple'
|
|
26
|
-
| 'amber'
|
|
27
|
-
| 'emerald'
|
|
28
|
-
| 'cyan'
|
|
29
|
-
| 'indigo'
|
|
30
|
-
| 'teal'
|
|
31
|
-
| 'violet'
|
|
32
|
-
| 'pink'
|
|
33
|
-
| 'sky'
|
|
19
|
+
export type LabelColor = AccentColor
|
|
34
20
|
|
|
35
21
|
export interface LabelProps {
|
|
36
22
|
text: string
|
|
@@ -94,7 +80,7 @@ const sizeConfig = {
|
|
|
94
80
|
}
|
|
95
81
|
|
|
96
82
|
/** Smart capitalize: capitalizes first letter of each word separated by spaces or dashes */
|
|
97
|
-
function smartCapitalize(value: string): string {
|
|
83
|
+
export function smartCapitalize(value: string): string {
|
|
98
84
|
return value.replace(/(^|[\s-])(\w)/g, (_match, separator: string, char: string) => separator + char.toUpperCase())
|
|
99
85
|
}
|
|
100
86
|
|
|
@@ -139,6 +125,10 @@ export function Label({
|
|
|
139
125
|
<>
|
|
140
126
|
{hasProgress && (
|
|
141
127
|
<span
|
|
128
|
+
role="progressbar"
|
|
129
|
+
aria-valuenow={Math.min(progress, 100)}
|
|
130
|
+
aria-valuemin={0}
|
|
131
|
+
aria-valuemax={100}
|
|
142
132
|
className={`absolute inset-y-0 left-0 ${progressFillColors[color]} rounded-[inherit]`}
|
|
143
133
|
style={{ width: `${Math.min(progress, 100)}%` }}
|
|
144
134
|
/>
|
|
@@ -233,15 +233,15 @@ export function LayoutTabBar({ tabs, activeId, onSelect, onClose, onReorder, cla
|
|
|
233
233
|
|
|
234
234
|
{/* Close button */}
|
|
235
235
|
{tab.closable && onClose && (
|
|
236
|
-
<
|
|
237
|
-
|
|
236
|
+
<button
|
|
237
|
+
type="button"
|
|
238
|
+
aria-label={`Close ${tab.title}`}
|
|
238
239
|
tabIndex={-1}
|
|
239
240
|
onClick={(e) => { e.stopPropagation(); onClose(tab.id) }}
|
|
240
|
-
onKeyDown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); onClose(tab.id) } }}
|
|
241
241
|
className="absolute right-1.5 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 hover:bg-neutral-700 rounded p-0.5 transition-opacity cursor-pointer"
|
|
242
242
|
>
|
|
243
243
|
<X className="w-3 h-3" />
|
|
244
|
-
</
|
|
244
|
+
</button>
|
|
245
245
|
)}
|
|
246
246
|
</button>
|
|
247
247
|
|
|
@@ -269,7 +269,7 @@ export function LayoutTabBar({ tabs, activeId, onSelect, onClose, onReorder, cla
|
|
|
269
269
|
const ghostSubIconC = getTextClass(t.subtitleIconColor || t.subtitleColor || t.selectedColor || t.color)
|
|
270
270
|
return (
|
|
271
271
|
<div className="fixed z-50 pointer-events-none opacity-90" style={{ left: drag.x + 12, top: drag.y - 20 }}>
|
|
272
|
-
<div className={cn('flex flex-col gap-0.5 px-2.5 py-1.5 rounded-t-lg border-t-2 shadow-
|
|
272
|
+
<div className={cn('flex flex-col gap-0.5 px-2.5 py-1.5 rounded-t-lg border-t-2 shadow-lg bg-neutral-800', ghostBorder)}>
|
|
273
273
|
<div className="flex items-center gap-1.5">
|
|
274
274
|
{t.icon && <span className={cn('shrink-0 inline-flex', ghostIconC)} style={{ width: 14, height: 14 }}>{t.icon}</span>}
|
|
275
275
|
<span className={cn('text-md font-medium', ghostTitleC)}>{t.title}</span>
|
package/components/ui/modal.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useId, useRef, useState } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { Info, AlertTriangle, AlertCircle, Check } from 'lucide-react'
|
|
4
|
+
import { useModalBehavior } from '../hooks/use-modal-behavior.ts'
|
|
4
5
|
import { IconButton, type ActionItem } from './icon-button.tsx'
|
|
5
6
|
import { FormActions } from './form-actions.tsx'
|
|
6
7
|
import type { ReactNode } from 'react'
|
|
@@ -41,35 +42,26 @@ interface ModalProps {
|
|
|
41
42
|
|
|
42
43
|
function Modal({ isOpen, onClose, title, children, kind = 'info', size = 'md', hideCloseButton = false, headerActions, testId }: ModalProps) {
|
|
43
44
|
const modalRef = useRef<HTMLDivElement>(null)
|
|
45
|
+
const titleId = useId()
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
if (!isOpen) return
|
|
47
|
-
const handleEscape = (e: KeyboardEvent) => {
|
|
48
|
-
if (e.key === 'Escape') onClose()
|
|
49
|
-
}
|
|
50
|
-
document.addEventListener('keydown', handleEscape)
|
|
51
|
-
return () => document.removeEventListener('keydown', handleEscape)
|
|
52
|
-
}, [isOpen, onClose])
|
|
53
|
-
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (!isOpen) return
|
|
56
|
-
document.body.style.overflow = 'hidden'
|
|
57
|
-
return () => { document.body.style.overflow = '' }
|
|
58
|
-
}, [isOpen])
|
|
47
|
+
useModalBehavior(isOpen, onClose, modalRef)
|
|
59
48
|
|
|
60
49
|
if (!isOpen) return null
|
|
61
50
|
|
|
62
51
|
return createPortal(
|
|
63
52
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
64
|
-
<div className="absolute inset-0 bg-[var(--dialog-backdrop)]
|
|
53
|
+
<div className="absolute inset-0 bg-[var(--dialog-backdrop)]" onClick={onClose} aria-hidden="true" />
|
|
65
54
|
<div
|
|
66
55
|
ref={modalRef}
|
|
56
|
+
role="dialog"
|
|
57
|
+
aria-modal="true"
|
|
58
|
+
aria-labelledby={titleId}
|
|
67
59
|
data-testid={testId}
|
|
68
|
-
className={`relative bg-neutral-900 border border-neutral-700 rounded-xl shadow-
|
|
60
|
+
className={`relative bg-neutral-900 border border-neutral-700 rounded-xl shadow-lg ${SIZE_CLASSES[size]} w-full mx-4 overflow-hidden`}
|
|
69
61
|
>
|
|
70
62
|
<div className="flex items-center gap-3 px-5 py-4 border-b border-neutral-800">
|
|
71
63
|
{KIND_ICON[kind]}
|
|
72
|
-
<h3 className="text-lg font-semibold text-white flex-1">{title}</h3>
|
|
64
|
+
<h3 id={titleId} className="text-lg font-semibold text-white flex-1 min-w-0 truncate">{title}</h3>
|
|
73
65
|
{headerActions?.map((a, i) => <IconButton key={i} {...a} />)}
|
|
74
66
|
{!hideCloseButton && (
|
|
75
67
|
<IconButton
|
|
@@ -1,24 +1,9 @@
|
|
|
1
1
|
/** Navigation card with icon, description, label, and hover lift effect. */
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
Settings, Puzzle, Zap, Shield, Folder, Code, Database, Globe,
|
|
5
|
-
Terminal, Star, Bell, User, Users, File, Tag, Sparkles, Search,
|
|
6
|
-
Lock, Mail, Cloud, Heart, Bookmark, Pin, Bot, Plug,
|
|
7
|
-
} from 'lucide-react'
|
|
8
|
-
import type { LucideIcon } from 'lucide-react'
|
|
9
|
-
import type { IconName } from './icon-button.tsx'
|
|
3
|
+
import { iconMap, type IconName } from './icon-button.tsx'
|
|
10
4
|
import { Label, type LabelColor } from './label.tsx'
|
|
11
5
|
import { cn } from '../lib/cn.ts'
|
|
12
6
|
|
|
13
|
-
const iconSubset: Partial<Record<IconName, LucideIcon>> = {
|
|
14
|
-
settings: Settings, puzzle: Puzzle, zap: Zap, shield: Shield,
|
|
15
|
-
folder: Folder, code: Code, database: Database, globe: Globe,
|
|
16
|
-
terminal: Terminal, star: Star, bell: Bell, user: User,
|
|
17
|
-
users: Users, file: File, tag: Tag, sparkles: Sparkles,
|
|
18
|
-
search: Search, lock: Lock, mail: Mail, cloud: Cloud,
|
|
19
|
-
heart: Heart, bookmark: Bookmark, pin: Pin, bot: Bot, plug: Plug,
|
|
20
|
-
}
|
|
21
|
-
|
|
22
7
|
export interface NavCardProps {
|
|
23
8
|
title: string
|
|
24
9
|
description?: string
|
|
@@ -45,7 +30,7 @@ export function NavCard({
|
|
|
45
30
|
disabled = false,
|
|
46
31
|
className,
|
|
47
32
|
}: NavCardProps) {
|
|
48
|
-
const Icon = IconComponent ?? (icon ?
|
|
33
|
+
const Icon = IconComponent ?? (icon ? iconMap[icon] : undefined)
|
|
49
34
|
|
|
50
35
|
return (
|
|
51
36
|
<button
|
|
@@ -74,7 +59,7 @@ export function NavCard({
|
|
|
74
59
|
</div>
|
|
75
60
|
)}
|
|
76
61
|
|
|
77
|
-
<h3 className="text-md font-medium text-neutral-200">{title}</h3>
|
|
62
|
+
<h3 className="text-md font-medium text-neutral-200 truncate">{title}</h3>
|
|
78
63
|
|
|
79
64
|
{description && (
|
|
80
65
|
<p className="mt-1 text-sm text-neutral-500 leading-relaxed line-clamp-2">{description}</p>
|