@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.
- package/components/hooks/use-modal-behavior.ts +32 -3
- package/components/sections/golden-snapshots/file-diff-viewer.tsx +1 -1
- package/components/sections/golden-snapshots/status-overview.tsx +1 -1
- package/components/ui/action-dialog.tsx +14 -6
- package/components/ui/ai-action-button.tsx +2 -4
- package/components/ui/badge.tsx +12 -4
- package/components/ui/breadcrumb.tsx +5 -5
- package/components/ui/checkbox.tsx +17 -11
- package/components/ui/collapsible-section.tsx +1 -0
- package/components/ui/confirm-badge.tsx +12 -4
- package/components/ui/cookie-consent.tsx +1 -1
- package/components/ui/extension-list-card.tsx +1 -1
- package/components/ui/file-tree.tsx +4 -4
- package/components/ui/filter-dropdown.tsx +5 -2
- package/components/ui/form-actions.tsx +7 -5
- package/components/ui/icon-button.tsx +5 -5
- package/components/ui/input.tsx +8 -3
- package/components/ui/label.tsx +4 -0
- package/components/ui/layout-tab-bar.tsx +5 -5
- package/components/ui/modal.tsx +9 -5
- package/components/ui/nav-card.tsx +1 -1
- package/components/ui/navigation-bar.tsx +4 -4
- package/components/ui/number-input.tsx +6 -0
- package/components/ui/segmented-toggle.tsx +2 -0
- package/components/ui/select.tsx +6 -3
- package/components/ui/selection-grid.tsx +4 -0
- package/components/ui/setting-row.tsx +4 -2
- package/components/ui/settings-card.tsx +2 -2
- package/components/ui/settings-info-box.tsx +1 -2
- package/components/ui/sort-dropdown.tsx +8 -5
- package/components/ui/tab-bar.tsx +14 -4
- package/components/ui/toggle.tsx +19 -11
- package/components/ui/tooltip.tsx +5 -5
- package/dist/index.d.ts +13 -7
- package/dist/index.js +258 -156
- 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-
|
|
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-
|
|
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
|
-
|
|
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)]
|
|
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-
|
|
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={
|
|
130
|
+
active={isRunning || isCompleted}
|
|
131
|
+
className={className}
|
|
134
132
|
testId={testId}
|
|
135
133
|
/>
|
|
136
134
|
)
|
package/components/ui/badge.tsx
CHANGED
|
@@ -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={
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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={
|
|
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={
|
|
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
|
|
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="
|
|
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). */
|
|
@@ -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={
|
|
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: '
|
|
264
|
-
warning: { color: 'amber' as const, active: true, animation: '
|
|
265
|
-
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: '' },
|
|
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
|
>
|
package/components/ui/input.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
)
|
package/components/ui/label.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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,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)]
|
|
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-
|
|
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>
|