@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.
Files changed (61) hide show
  1. package/components/hooks/use-click-outside.ts +10 -3
  2. package/components/hooks/use-modal-behavior.ts +53 -0
  3. package/components/hooks/use-navigation-history.ts +7 -2
  4. package/components/hooks/use-resizable-sidebar.ts +38 -0
  5. package/components/lib/form-colors.ts +40 -0
  6. package/components/sections/captured-issues/captured-issues-panel.tsx +3 -3
  7. package/components/sections/captured-issues/use-captured-issues.ts +9 -3
  8. package/components/sections/golden-snapshots/file-diff-viewer.tsx +1 -1
  9. package/components/sections/golden-snapshots/status-overview.tsx +1 -1
  10. package/components/sections/prompt-editor/file-type-tabbed-prompt-editor.tsx +4 -40
  11. package/components/sections/prompt-editor/index.ts +0 -7
  12. package/components/sections/prompt-editor/simulator-prompt-editor.tsx +4 -40
  13. package/components/sections/prompt-editor/tabbed-prompt-editor.tsx +4 -36
  14. package/components/sections/snippets-editor/snippets-editor.tsx +6 -39
  15. package/components/settings/SettingsHeader.tsx +0 -1
  16. package/components/settings/SettingsTreeNav.tsx +9 -12
  17. package/components/ui/action-dialog.tsx +19 -55
  18. package/components/ui/ai-action-button.tsx +2 -4
  19. package/components/ui/badge.tsx +15 -23
  20. package/components/ui/breadcrumb.tsx +11 -71
  21. package/components/ui/checkbox.tsx +19 -27
  22. package/components/ui/collapsible-section.tsx +4 -41
  23. package/components/ui/confirm-badge.tsx +14 -23
  24. package/components/ui/cookie-consent.tsx +18 -2
  25. package/components/ui/debounce-border-overlay.tsx +31 -0
  26. package/components/ui/detail-section.tsx +2 -19
  27. package/components/ui/editor-placeholder-card.tsx +10 -9
  28. package/components/ui/execution-details-panel.tsx +2 -7
  29. package/components/ui/extension-list-card.tsx +1 -1
  30. package/components/ui/file-structure-section.tsx +3 -18
  31. package/components/ui/file-tree.tsx +6 -18
  32. package/components/ui/files-panel.tsx +3 -11
  33. package/components/ui/filter-dropdown.tsx +5 -2
  34. package/components/ui/form-actions.tsx +11 -8
  35. package/components/ui/icon-button.tsx +7 -6
  36. package/components/ui/input.tsx +18 -29
  37. package/components/ui/label.tsx +7 -17
  38. package/components/ui/layout-tab-bar.tsx +5 -5
  39. package/components/ui/modal.tsx +10 -18
  40. package/components/ui/nav-card.tsx +3 -18
  41. package/components/ui/navigation-bar.tsx +12 -73
  42. package/components/ui/number-input.tsx +6 -0
  43. package/components/ui/registry-browser.tsx +6 -20
  44. package/components/ui/registry-card.tsx +3 -7
  45. package/components/ui/resizable-textarea.tsx +13 -35
  46. package/components/ui/segmented-toggle.tsx +4 -1
  47. package/components/ui/select.tsx +8 -14
  48. package/components/ui/selection-grid.tsx +6 -50
  49. package/components/ui/setting-row.tsx +5 -5
  50. package/components/ui/settings-card.tsx +2 -2
  51. package/components/ui/settings-info-box.tsx +6 -24
  52. package/components/ui/sort-dropdown.tsx +8 -5
  53. package/components/ui/status-card.tsx +2 -13
  54. package/components/ui/tab-bar.tsx +17 -33
  55. package/components/ui/toggle.tsx +22 -30
  56. package/components/ui/tooltip.tsx +11 -23
  57. package/dist/index.d.ts +71 -142
  58. package/dist/index.js +1630 -2436
  59. package/index.ts +8 -7
  60. package/package.json +9 -1
  61. 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 [isCopied, setIsCopied] = useState(false)
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
- setIsCopied(true)
82
- setTimeout(() => setIsCopied(false), 1500)
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
- setIsCopied(true)
93
- setTimeout(() => setIsCopied(false), 1500)
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={isCopied ? 'check' : 'copy'}
123
+ icon={isPlaceholderCopied ? 'check' : 'copy'}
123
124
  onClick={handleCopyPlaceholder}
124
125
  size="sm"
125
- color={isCopied ? 'green' : 'neutral'}
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={isCopied ? 'check' : 'copy'}
133
+ icon={isValueCopied ? 'check' : 'copy'}
133
134
  onClick={handleCopyValue}
134
135
  size="sm"
135
- color={isCopied ? 'green' : 'neutral'}
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: ExecutionDetailRow[]
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 { CollapseButton, type IconButtonColor } from './icon-button.tsx'
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={ACCENT_BUTTON[accentColor]}
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 { IconName } from './icon-button.tsx'
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 && iconSubset[entry.icon]) return iconSubset[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 && iconSubset[entry.icon]) return iconSubset[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="whitespace-nowrap">{selectedLabel}</span>
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-xl overflow-hidden`}>
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 border-t border-neutral-700',
38
- modal: 'px-4 py-3 border-t border-neutral-700',
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={`flex items-center ${hasLeft ? 'justify-between' : 'justify-end'} gap-2 ${paddingClass}`}>
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 = 'blue' | 'green' | 'red' | 'orange' | 'cyan' | 'yellow' | 'purple' | 'indigo' | 'emerald' | 'amber' | 'violet' | 'neutral' | 'sky' | 'pink' | 'teal'
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: 'animate-pulse' },
263
- warning: { color: 'amber' as const, active: true, animation: 'animate-pulse' },
264
- error: { color: 'red' as const, active: true, animation: 'animate-pulse' },
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
  >
@@ -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
- title={isPasswordVisible ? 'Hide' : 'Reveal'}
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
- <svg
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
  )
@@ -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
- <span
237
- role="button"
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
- </span>
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-xl bg-neutral-800', ghostBorder)}>
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>
@@ -1,6 +1,7 @@
1
- import { useEffect, useRef, useState } from 'react'
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
- useEffect(() => {
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)] backdrop-blur-sm" onClick={onClose} />
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-2xl ${SIZE_CLASSES[size]} w-full mx-4 overflow-hidden`}
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 ? iconSubset[icon] : undefined)
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>