@toolr/ui-design 0.1.7 → 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 (36) hide show
  1. package/components/hooks/use-modal-behavior.ts +32 -3
  2. package/components/sections/golden-snapshots/file-diff-viewer.tsx +1 -1
  3. package/components/sections/golden-snapshots/status-overview.tsx +1 -1
  4. package/components/ui/action-dialog.tsx +14 -6
  5. package/components/ui/ai-action-button.tsx +2 -4
  6. package/components/ui/badge.tsx +12 -4
  7. package/components/ui/breadcrumb.tsx +5 -5
  8. package/components/ui/checkbox.tsx +17 -11
  9. package/components/ui/collapsible-section.tsx +1 -0
  10. package/components/ui/confirm-badge.tsx +12 -4
  11. package/components/ui/cookie-consent.tsx +1 -1
  12. package/components/ui/extension-list-card.tsx +1 -1
  13. package/components/ui/file-tree.tsx +4 -4
  14. package/components/ui/filter-dropdown.tsx +5 -2
  15. package/components/ui/form-actions.tsx +7 -5
  16. package/components/ui/icon-button.tsx +5 -5
  17. package/components/ui/input.tsx +8 -3
  18. package/components/ui/label.tsx +4 -0
  19. package/components/ui/layout-tab-bar.tsx +5 -5
  20. package/components/ui/modal.tsx +9 -5
  21. package/components/ui/nav-card.tsx +1 -1
  22. package/components/ui/navigation-bar.tsx +4 -4
  23. package/components/ui/number-input.tsx +6 -0
  24. package/components/ui/segmented-toggle.tsx +2 -0
  25. package/components/ui/select.tsx +6 -3
  26. package/components/ui/selection-grid.tsx +4 -0
  27. package/components/ui/setting-row.tsx +4 -2
  28. package/components/ui/settings-card.tsx +2 -2
  29. package/components/ui/settings-info-box.tsx +1 -2
  30. package/components/ui/sort-dropdown.tsx +8 -5
  31. package/components/ui/tab-bar.tsx +14 -4
  32. package/components/ui/toggle.tsx +19 -11
  33. package/components/ui/tooltip.tsx +5 -5
  34. package/dist/index.d.ts +13 -7
  35. package/dist/index.js +258 -156
  36. package/package.json +9 -1
@@ -1,9 +1,9 @@
1
- import { useEffect } from 'react'
1
+ import { useEffect, type RefObject } from 'react'
2
2
 
3
3
  /**
4
- * Shared modal behavior: Escape key to close + body overflow lock.
4
+ * Shared modal behavior: Escape key to close + body overflow lock + focus trap.
5
5
  */
6
- export function useModalBehavior(isOpen: boolean, onClose: () => void): void {
6
+ export function useModalBehavior(isOpen: boolean, onClose: () => void, containerRef?: RefObject<HTMLElement | null>): void {
7
7
  // Escape key handler
8
8
  useEffect(() => {
9
9
  if (!isOpen) return
@@ -21,4 +21,33 @@ export function useModalBehavior(isOpen: boolean, onClose: () => void): void {
21
21
  document.body.style.overflow = 'hidden'
22
22
  return () => { document.body.style.overflow = prev }
23
23
  }, [isOpen])
24
+
25
+ // Focus trap: keep Tab/Shift+Tab within the modal
26
+ useEffect(() => {
27
+ if (!isOpen || !containerRef?.current) return
28
+ const container = containerRef.current
29
+
30
+ // Auto-focus first focusable element
31
+ const focusable = container.querySelectorAll<HTMLElement>(
32
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
33
+ )
34
+ if (focusable.length > 0) focusable[0].focus()
35
+
36
+ const handler = (e: KeyboardEvent) => {
37
+ if (e.key !== 'Tab') return
38
+ const nodes = container.querySelectorAll<HTMLElement>(
39
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
40
+ )
41
+ if (nodes.length === 0) return
42
+ const first = nodes[0]
43
+ const last = nodes[nodes.length - 1]
44
+ if (e.shiftKey) {
45
+ if (document.activeElement === first) { e.preventDefault(); last.focus() }
46
+ } else {
47
+ if (document.activeElement === last) { e.preventDefault(); first.focus() }
48
+ }
49
+ }
50
+ document.addEventListener('keydown', handler)
51
+ return () => document.removeEventListener('keydown', handler)
52
+ }, [isOpen, containerRef])
24
53
  }
@@ -391,7 +391,7 @@ export function FileDiffViewer({ sync, componentLabels, monacoTheme, renderFileI
391
391
  />
392
392
  {devtools && hasUnsavedChanges && (
393
393
  <IconButton
394
- icon={<Save className={saving ? 'animate-pulse' : ''} />}
394
+ icon={<Save className={saving ? 'animate-spin' : ''} />}
395
395
  onClick={handleSaveLiveFile}
396
396
  disabled={saving}
397
397
  color="amber"
@@ -165,7 +165,7 @@ export function StatusOverview({
165
165
  tooltip={{ title: 'Reset options', description: 'Reset live files to golden' }}
166
166
  />
167
167
  {showResetMenu && (
168
- <div ref={resetMenuDropdownRef} className="absolute right-0 top-full mt-1 w-56 bg-neutral-850 border border-neutral-700 rounded-lg shadow-xl z-50 py-1 overflow-hidden">
168
+ <div ref={resetMenuDropdownRef} className="absolute right-0 top-full mt-1 w-56 bg-neutral-850 border border-neutral-700 rounded-lg shadow-lg z-50 py-1 overflow-hidden">
169
169
  <button
170
170
  onClick={() => { onResetAll(); setShowResetMenu(false) }}
171
171
  className="w-full text-left px-3 py-2 text-md text-neutral-300 hover:bg-neutral-700 transition-colors"
@@ -11,6 +11,7 @@
11
11
  * Footer: FormActions (status text, cancel/submit)
12
12
  */
13
13
 
14
+ import { useId, useRef } from 'react'
14
15
  import { createPortal } from 'react-dom'
15
16
  import { useModalBehavior } from '../hooks/use-modal-behavior.ts'
16
17
  import { iconMap, IconButton } from './icon-button.tsx'
@@ -130,7 +131,10 @@ export function ActionDialog({
130
131
  children,
131
132
  className,
132
133
  }: ActionDialogProps) {
133
- useModalBehavior(true, () => onCancel?.())
134
+ const dialogRef = useRef<HTMLDivElement>(null)
135
+ const titleId = useId()
136
+
137
+ useModalBehavior(true, () => onCancel?.(), dialogRef)
134
138
 
135
139
  const Icon = icon ? iconMap[icon] : null
136
140
  const hasSelection = ((items && items.length > 0) || (presets && presets.length > 0)) && selectedIds && onSelect
@@ -150,10 +154,14 @@ export function ActionDialog({
150
154
 
151
155
  return createPortal(
152
156
  <div className="fixed inset-0 z-50 flex items-center justify-center">
153
- <div className="absolute inset-0 bg-[var(--dialog-backdrop)] backdrop-blur-sm" onClick={onCancel} />
157
+ <div className="absolute inset-0 bg-[var(--dialog-backdrop)]" onClick={onCancel} aria-hidden="true" />
154
158
  <div
159
+ ref={dialogRef}
160
+ role="dialog"
161
+ aria-modal="true"
162
+ aria-labelledby={titleId}
155
163
  className={cn(
156
- 'relative bg-neutral-950 border border-neutral-700 rounded-xl shadow-2xl w-full max-w-[800px] mx-4 flex flex-col',
164
+ 'relative bg-neutral-950 border border-neutral-700 rounded-xl shadow-lg w-full max-w-[800px] mx-4 flex flex-col',
157
165
  'max-h-[80vh]',
158
166
  className,
159
167
  )}
@@ -166,12 +174,12 @@ export function ActionDialog({
166
174
  style={iconColor ? { color: iconColor } : undefined}
167
175
  />
168
176
  )}
169
- <div className="flex flex-col">
170
- <span className="text-md font-semibold text-neutral-200">
177
+ <div className="flex flex-col min-w-0">
178
+ <span id={titleId} className="text-md font-semibold text-neutral-200 truncate">
171
179
  {title}
172
180
  </span>
173
181
  {subtitle && (
174
- <span className="text-sm text-neutral-500">{subtitle}</span>
182
+ <span className="text-sm text-neutral-500 truncate">{subtitle}</span>
175
183
  )}
176
184
  </div>
177
185
  <div className="flex-1" />
@@ -119,8 +119,6 @@ export function AiActionButton({
119
119
  }, [tooltip, forceDisabled, disabledReason, isRunning, isCompleted, runningTooltipTitle, completedTooltipTitle])
120
120
 
121
121
  const isDisabled = forceDisabled
122
- const blinkClass = isCompleted ? 'animate-pulse' : ''
123
-
124
122
  return (
125
123
  <IconButton
126
124
  icon={resolvedIcon}
@@ -129,8 +127,8 @@ export function AiActionButton({
129
127
  disabled={isDisabled}
130
128
  onClick={isDisabled ? () => {} : onClick}
131
129
  tooltip={resolvedTooltip}
132
- active={isRunning}
133
- className={`${className ?? ''} ${blinkClass}`.trim() || undefined}
130
+ active={isRunning || isCompleted}
131
+ className={className}
134
132
  testId={testId}
135
133
  />
136
134
  )
@@ -13,7 +13,9 @@
13
13
  * - 5 size variants (xss, xs, sm, md, lg)
14
14
  */
15
15
 
16
+ import { memo } from 'react'
16
17
  import { FORM_COLORS, type AccentColor } from '../lib/form-colors.ts'
18
+ import { cn } from '../lib/cn.ts'
17
19
 
18
20
  export type BadgeColor = AccentColor
19
21
 
@@ -33,11 +35,11 @@ const sizeClasses = {
33
35
  lg: 'min-w-[22px] h-[22px] px-1.5 text-sm',
34
36
  }
35
37
 
36
- export function Badge({
38
+ export const Badge = memo(function Badge({
37
39
  value,
38
40
  color = 'neutral',
39
41
  size = 'sm',
40
- className = '',
42
+ className,
41
43
  testId,
42
44
  }: BadgeProps) {
43
45
  const display = typeof value === 'number' && value > 99 ? '99+' : value
@@ -45,9 +47,15 @@ export function Badge({
45
47
  return (
46
48
  <span
47
49
  data-testid={testId}
48
- className={`inline-flex items-center justify-center border rounded-full font-medium leading-none tabular-nums ${FORM_COLORS[color].border} ${FORM_COLORS[color].accent} ${sizeClasses[size]} ${className}`}
50
+ className={cn(
51
+ 'inline-flex items-center justify-center border rounded-full font-medium leading-none tabular-nums',
52
+ FORM_COLORS[color].border,
53
+ FORM_COLORS[color].accent,
54
+ sizeClasses[size],
55
+ className,
56
+ )}
49
57
  >
50
58
  {display}
51
59
  </span>
52
60
  )
53
- }
61
+ })
@@ -67,7 +67,7 @@ export function Breadcrumb({
67
67
  const isBox = variant === 'box'
68
68
 
69
69
  return (
70
- <nav className={cn('flex items-center', className)}>
70
+ <nav className={cn('flex items-center min-w-0', className)}>
71
71
  <div className={cn(
72
72
  'flex items-center gap-1',
73
73
  isBox && [s.px, s.py, 'bg-neutral-800/50 border border-neutral-700/50 rounded-lg'],
@@ -79,14 +79,14 @@ export function Breadcrumb({
79
79
  const isFirstPlain = !isBox && index === 0
80
80
 
81
81
  return (
82
- <div key={segment.id} className="flex items-center gap-1">
82
+ <div key={segment.id} className="flex items-center gap-1 min-w-0">
83
83
  {index > 0 && <Separator type={separator} size={size} />}
84
84
  {isClickable ? (
85
85
  <button
86
86
  type="button"
87
87
  onClick={segment.onClick}
88
88
  className={cn(
89
- 'flex items-center gap-1.5 pr-2 py-0.5 rounded-md transition-colors cursor-pointer',
89
+ 'flex items-center gap-1.5 pr-2 py-0.5 rounded-md transition-colors cursor-pointer min-w-0',
90
90
  isFirstPlain ? 'pl-0' : 'pl-2',
91
91
  s.text,
92
92
  'font-medium hover:text-white',
@@ -94,12 +94,12 @@ export function Breadcrumb({
94
94
  )}
95
95
  >
96
96
  {segment.icon && <SegmentIcon icon={segment.icon} color={segment.color} size={size} />}
97
- <span>{segment.label}</span>
97
+ <span className="truncate max-w-[200px]">{segment.label}</span>
98
98
  </button>
99
99
  ) : (
100
100
  <div
101
101
  className={cn(
102
- 'flex items-center gap-1.5 pr-2 py-0.5 rounded-md',
102
+ 'flex items-center gap-1.5 pr-2 py-0.5 rounded-md min-w-0',
103
103
  isFirstPlain ? 'pl-0' : 'pl-2',
104
104
  s.text,
105
105
  isLast
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { Check } from 'lucide-react'
11
11
  import { type AccentColor } from '../lib/form-colors.ts'
12
+ import { cn } from '../lib/cn.ts'
12
13
 
13
14
  export type CheckboxSize = 'xss' | 'xs' | 'sm' | 'md' | 'lg'
14
15
 
@@ -50,6 +51,8 @@ export interface CheckboxProps {
50
51
  color?: CheckboxColor
51
52
  variant?: CheckboxVariant
52
53
  className?: string
54
+ /** Accessible label — required for screen readers */
55
+ 'aria-label'?: string
53
56
  /** Test ID for E2E testing */
54
57
  testId?: string
55
58
  }
@@ -61,28 +64,31 @@ export function Checkbox({
61
64
  size = 'sm',
62
65
  color = 'blue',
63
66
  variant = 'outline',
64
- className = '',
67
+ className,
68
+ 'aria-label': ariaLabel,
65
69
  testId,
66
70
  }: CheckboxProps) {
67
71
  const s = CHECKBOX_SIZES[size]
68
72
  const c = CHECKBOX_COLORS[color]
69
- const uncheckedStyle = variant === 'outline'
70
- ? `${c.border} ${c.hover}`
71
- : `bg-neutral-700 ${c.border} ${c.hover}`
72
73
  return (
73
74
  <button
74
75
  type="button"
76
+ role="checkbox"
77
+ aria-checked={checked}
78
+ aria-label={ariaLabel}
75
79
  onClick={() => !disabled && onChange(!checked)}
76
80
  disabled={disabled}
77
81
  data-testid={testId}
78
- className={`
79
- ${s.box} rounded border flex items-center justify-center transition-colors flex-shrink-0
80
- cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed
81
- ${checked ? `${c.bg} ${c.border}` : uncheckedStyle}
82
- ${className}
83
- `}
82
+ className={cn(
83
+ 'rounded border flex items-center justify-center transition-colors flex-shrink-0 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
84
+ s.box,
85
+ checked
86
+ ? cn(c.bg, c.border)
87
+ : cn(variant === 'filled' && 'bg-neutral-700', c.border, c.hover),
88
+ className,
89
+ )}
84
90
  >
85
- {checked && <Check className={`${s.icon} ${c.icon}`} />}
91
+ {checked && <Check className={cn(s.icon, c.icon)} />}
86
92
  </button>
87
93
  )
88
94
  }
@@ -32,6 +32,7 @@ export function CollapsibleSection({
32
32
  <div className={cn('border-b border-neutral-700', className)}>
33
33
  <button
34
34
  type="button"
35
+ aria-expanded={open}
35
36
  onClick={() => setOpen(!open)}
36
37
  className="flex w-full items-center gap-2 py-2.5 px-1 text-left hover:bg-neutral-700/30 transition-colors cursor-pointer"
37
38
  >
@@ -11,8 +11,10 @@
11
11
  * - 5 size variants (xss, xs, sm, md, lg)
12
12
  */
13
13
 
14
+ import { memo } from 'react'
14
15
  import { Check } from 'lucide-react'
15
16
  import { FORM_COLORS, type AccentColor } from '../lib/form-colors.ts'
17
+ import { cn } from '../lib/cn.ts'
16
18
 
17
19
  export type ConfirmBadgeColor = AccentColor
18
20
 
@@ -39,18 +41,24 @@ const iconSizeClasses = {
39
41
  lg: 'w-4 h-4',
40
42
  }
41
43
 
42
- export function ConfirmBadge({
44
+ export const ConfirmBadge = memo(function ConfirmBadge({
43
45
  color = 'neutral',
44
46
  size = 'sm',
45
- className = '',
47
+ className,
46
48
  testId,
47
49
  }: ConfirmBadgeProps) {
48
50
  return (
49
51
  <span
50
52
  data-testid={testId}
51
- className={`inline-flex items-center justify-center border ${FORM_COLORS[color].border} ${FORM_COLORS[color].accent} ${sizeClasses[size]} ${className}`}
53
+ className={cn(
54
+ 'inline-flex items-center justify-center border',
55
+ FORM_COLORS[color].border,
56
+ FORM_COLORS[color].accent,
57
+ sizeClasses[size],
58
+ className,
59
+ )}
52
60
  >
53
61
  <Check className={iconSizeClasses[size]} />
54
62
  </span>
55
63
  )
56
- }
64
+ })
@@ -55,7 +55,7 @@ export function CookieConsent({
55
55
  if (!isVisible) return null
56
56
 
57
57
  return (
58
- <div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-neutral-900/95 backdrop-blur-sm border-t border-neutral-700/50">
58
+ <div className="fixed bottom-0 left-0 right-0 z-50 p-4 bg-neutral-900 border-t border-neutral-700/50">
59
59
  <div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-start sm:items-center gap-4">
60
60
  <div className="flex-grow">
61
61
  <p className="text-md text-neutral-200 mb-1">{heading}</p>
@@ -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 && (
@@ -61,7 +61,7 @@ export function FileTree({ nodes, rootName, selectedPath, onSelectFile, prefix =
61
61
  if (rootName) {
62
62
  const rootNode: FileTreeNode = { name: rootName, type: 'directory', children: nodes }
63
63
  return (
64
- <ul className="space-y-0.5">
64
+ <ul role="tree" className="space-y-0.5">
65
65
  <FileTreeNodeItem
66
66
  node={rootNode}
67
67
  path={rootName}
@@ -76,7 +76,7 @@ export function FileTree({ nodes, rootName, selectedPath, onSelectFile, prefix =
76
76
  }
77
77
 
78
78
  return (
79
- <ul className="space-y-0.5">
79
+ <ul role="tree" className="space-y-0.5">
80
80
  {nodes.filter(nodeHasFiles).map((node) => {
81
81
  const fullPath = prefix ? `${prefix}/${node.name}` : node.name
82
82
  return (
@@ -120,7 +120,7 @@ function FileTreeNodeItem({ node, path, selectedPath, onSelectFile, expandedPath
120
120
  : `${base} cursor-pointer text-white hover:bg-neutral-700/50 hover:text-neutral-200`
121
121
 
122
122
  return (
123
- <li>
123
+ <li role="treeitem" aria-expanded={isDir ? expanded : undefined} aria-selected={isSelected}>
124
124
  <button
125
125
  onClick={isDir ? () => onTogglePath(path) : () => onSelectFile(path)}
126
126
  className={rowClass}
@@ -138,7 +138,7 @@ function FileTreeNodeItem({ node, path, selectedPath, onSelectFile, expandedPath
138
138
  <span className="truncate">{node.name}</span>
139
139
  </button>
140
140
  {isDir && expanded && node.children && (
141
- <ul className="ml-4 space-y-0.5">
141
+ <ul role="group" className="ml-4 space-y-0.5">
142
142
  {node.children.filter(nodeHasFiles).map((child) => {
143
143
  const childPath = `${path}/${child.name}`
144
144
  return (
@@ -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). */
@@ -66,14 +67,15 @@ export function FormActions({
66
67
  padding = 'normal',
67
68
  }: FormActionsProps) {
68
69
  const showBorder = border ?? DEFAULT_BORDER[padding]
69
- const paddingClass = showBorder
70
- ? `${PADDING_CLASSES[padding]} ${BORDER_CLASS}`
71
- : PADDING_CLASSES[padding]
72
-
73
70
  const hasLeft = onBack || statusText
74
71
 
75
72
  return (
76
- <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
+ )}>
77
79
  {hasLeft && (
78
80
  <div className="flex items-center gap-2">
79
81
  {onBack && (
@@ -260,9 +260,9 @@ const statusIcons: Record<IconButtonStatus, LucideIcon> = {
260
260
 
261
261
  const statusConfig = {
262
262
  loading: { color: undefined, active: true, animation: 'animate-spin' },
263
- success: { color: 'green' as const, active: true, animation: 'animate-pulse' },
264
- warning: { color: 'amber' as const, active: true, animation: 'animate-pulse' },
265
- 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: '' },
266
266
  }
267
267
 
268
268
  function resolveIcon(icon: IconName | ReactNode, status: IconButtonStatus | undefined): LucideIcon | null {
@@ -337,7 +337,7 @@ export function IconButton({
337
337
  href={href}
338
338
  target="_blank"
339
339
  rel="noopener noreferrer"
340
- aria-label={tooltip?.title}
340
+ aria-label={tooltip?.title || (typeof tooltip?.description === 'string' ? tooltip.description : undefined)}
341
341
  data-testid={testId}
342
342
  className={`${sharedClassName} cursor-pointer no-underline`}
343
343
  >
@@ -348,7 +348,7 @@ export function IconButton({
348
348
  type="button"
349
349
  onClick={onClick}
350
350
  disabled={disabled}
351
- aria-label={tooltip?.title}
351
+ aria-label={tooltip?.title || (typeof tooltip?.description === 'string' ? tooltip.description : undefined)}
352
352
  data-testid={testId}
353
353
  className={`${sharedClassName} cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed`}
354
354
  >
@@ -17,7 +17,7 @@
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
22
  import { DebounceBorderOverlay } from './debounce-border-overlay.tsx'
23
23
  import { FORM_COLORS, type FormColor } from '../lib/form-colors.ts'
@@ -84,6 +84,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
84
84
  const isSearch = type === 'search'
85
85
  const isPassword = type === 'password'
86
86
  const [isPasswordVisible, setIsPasswordVisible] = useState(false)
87
+ const errorId = useId()
87
88
 
88
89
  // Debounce state
89
90
  const [internalValue, setInternalValue] = useState(value)
@@ -146,6 +147,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
146
147
  onBlur={(e) => { setFocused(false); onBlurProp?.(e) }}
147
148
  disabled={disabled}
148
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' } : {})}
149
153
  {...searchAutoProps}
150
154
  className={`
151
155
  w-full border rounded-lg text-neutral-200 placeholder-neutral-500
@@ -162,6 +166,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
162
166
  {showClear && (
163
167
  <button
164
168
  type="button"
169
+ aria-label="Clear search"
165
170
  onClick={handleClear}
166
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"
167
172
  >
@@ -177,7 +182,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
177
182
  <button
178
183
  type="button"
179
184
  onClick={() => setIsPasswordVisible(!isPasswordVisible)}
180
- title={isPasswordVisible ? 'Hide' : 'Reveal'}
185
+ aria-label={isPasswordVisible ? 'Hide password' : 'Show password'}
181
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"
182
187
  >
183
188
  {isPasswordVisible ? <EyeOff className="w-2.5 h-2.5" /> : <Eye className="w-2.5 h-2.5" />}
@@ -188,7 +193,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input({
188
193
  )}
189
194
  </div>
190
195
  {typeof error === 'string' && error && (
191
- <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>
192
197
  )}
193
198
  </div>
194
199
  )
@@ -125,6 +125,10 @@ export function Label({
125
125
  <>
126
126
  {hasProgress && (
127
127
  <span
128
+ role="progressbar"
129
+ aria-valuenow={Math.min(progress, 100)}
130
+ aria-valuemin={0}
131
+ aria-valuemax={100}
128
132
  className={`absolute inset-y-0 left-0 ${progressFillColors[color]} rounded-[inherit]`}
129
133
  style={{ width: `${Math.min(progress, 100)}%` }}
130
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,4 +1,4 @@
1
- import { 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
4
  import { useModalBehavior } from '../hooks/use-modal-behavior.ts'
@@ -42,22 +42,26 @@ interface ModalProps {
42
42
 
43
43
  function Modal({ isOpen, onClose, title, children, kind = 'info', size = 'md', hideCloseButton = false, headerActions, testId }: ModalProps) {
44
44
  const modalRef = useRef<HTMLDivElement>(null)
45
+ const titleId = useId()
45
46
 
46
- useModalBehavior(isOpen, onClose)
47
+ useModalBehavior(isOpen, onClose, modalRef)
47
48
 
48
49
  if (!isOpen) return null
49
50
 
50
51
  return createPortal(
51
52
  <div className="fixed inset-0 z-50 flex items-center justify-center">
52
- <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" />
53
54
  <div
54
55
  ref={modalRef}
56
+ role="dialog"
57
+ aria-modal="true"
58
+ aria-labelledby={titleId}
55
59
  data-testid={testId}
56
- 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`}
57
61
  >
58
62
  <div className="flex items-center gap-3 px-5 py-4 border-b border-neutral-800">
59
63
  {KIND_ICON[kind]}
60
- <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>
61
65
  {headerActions?.map((a, i) => <IconButton key={i} {...a} />)}
62
66
  {!hideCloseButton && (
63
67
  <IconButton
@@ -59,7 +59,7 @@ export function NavCard({
59
59
  </div>
60
60
  )}
61
61
 
62
- <h3 className="text-md font-medium text-neutral-200">{title}</h3>
62
+ <h3 className="text-md font-medium text-neutral-200 truncate">{title}</h3>
63
63
 
64
64
  {description && (
65
65
  <p className="mt-1 text-sm text-neutral-500 leading-relaxed line-clamp-2">{description}</p>