@annondeveloper/ui-kit 0.2.0 → 0.2.2
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/README.md +1463 -127
- package/dist/form.js +1 -2
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +123 -65
- package/dist/index.js.map +1 -1
- package/package.json +24 -26
- package/src/components/animated-counter.tsx +6 -4
- package/src/components/color-input.tsx +27 -14
- package/src/components/command-bar.tsx +2 -1
- package/src/components/copy-block.tsx +7 -12
- package/src/components/data-table.tsx +7 -12
- package/src/components/diff-viewer.tsx +21 -0
- package/src/components/heatmap-calendar.tsx +6 -1
- package/src/components/infinite-scroll.tsx +8 -3
- package/src/components/live-feed.tsx +2 -0
- package/src/components/log-viewer.tsx +2 -0
- package/src/components/notification-stack.tsx +8 -1
- package/src/components/sortable-list.tsx +17 -13
- package/src/components/step-wizard.tsx +9 -1
- package/src/components/streaming-text.tsx +6 -9
- package/src/theme.css +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@annondeveloper/ui-kit",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A React component library with dark/light theming, built on Radix UI, Tailwind CSS v4, and Framer Motion.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"react-dom": "^19.0.0",
|
|
50
50
|
"react-hook-form": "^7.0.0",
|
|
51
51
|
"sonner": "^2.0.0",
|
|
52
|
-
"tailwind-merge": "^2.0.0"
|
|
52
|
+
"tailwind-merge": "^2.0.0 || ^3.0.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"react-hook-form": {
|
|
@@ -58,39 +58,37 @@
|
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@axe-core/react": "^4.11.1",
|
|
61
|
-
"@radix-ui/react-alert-dialog": "^1.
|
|
62
|
-
"@radix-ui/react-dropdown-menu": "^2.
|
|
63
|
-
"@radix-ui/react-popover": "^1.
|
|
64
|
-
"@radix-ui/react-select": "^2.
|
|
65
|
-
"@radix-ui/react-tooltip": "^1.
|
|
66
|
-
"@storybook/addon-a11y": "^
|
|
67
|
-
"@storybook/
|
|
68
|
-
"@storybook/
|
|
69
|
-
"@
|
|
70
|
-
"@storybook/react-vite": "^8.6.18",
|
|
71
|
-
"@tanstack/react-table": "^8.0.0",
|
|
61
|
+
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
62
|
+
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
63
|
+
"@radix-ui/react-popover": "^1.1.15",
|
|
64
|
+
"@radix-ui/react-select": "^2.2.6",
|
|
65
|
+
"@radix-ui/react-tooltip": "^1.2.8",
|
|
66
|
+
"@storybook/addon-a11y": "^10.3.0",
|
|
67
|
+
"@storybook/react": "^10.3.0",
|
|
68
|
+
"@storybook/react-vite": "^10.3.0",
|
|
69
|
+
"@tanstack/react-table": "^8.21.3",
|
|
72
70
|
"@testing-library/dom": "^10.4.1",
|
|
73
71
|
"@testing-library/jest-dom": "^6.9.1",
|
|
74
72
|
"@testing-library/react": "^16.3.2",
|
|
75
73
|
"@testing-library/user-event": "^14.6.1",
|
|
76
74
|
"@types/jest-axe": "^3.5.9",
|
|
77
|
-
"@types/react": "^19.
|
|
78
|
-
"@types/react-dom": "^19.
|
|
79
|
-
"@vitejs/plugin-react": "^6.0.
|
|
75
|
+
"@types/react": "^19.2.14",
|
|
76
|
+
"@types/react-dom": "^19.2.3",
|
|
77
|
+
"@vitejs/plugin-react": "^6.0.0",
|
|
80
78
|
"axe-core": "^4.11.1",
|
|
81
|
-
"clsx": "^2.
|
|
82
|
-
"framer-motion": "^12.
|
|
79
|
+
"clsx": "^2.1.1",
|
|
80
|
+
"framer-motion": "^12.38.0",
|
|
83
81
|
"jest-axe": "^10.0.0",
|
|
84
82
|
"jsdom": "^29.0.0",
|
|
85
|
-
"lucide-react": ">=0.
|
|
86
|
-
"react": "^19.
|
|
87
|
-
"react-dom": "^19.
|
|
83
|
+
"lucide-react": ">=0.577.0",
|
|
84
|
+
"react": "^19.2.4",
|
|
85
|
+
"react-dom": "^19.2.4",
|
|
88
86
|
"react-hook-form": "^7.71.2",
|
|
89
|
-
"sonner": "^2.0.
|
|
90
|
-
"storybook": "^
|
|
91
|
-
"tailwind-merge": "^
|
|
92
|
-
"tsup": "^8.
|
|
93
|
-
"typescript": "^5.
|
|
87
|
+
"sonner": "^2.0.7",
|
|
88
|
+
"storybook": "^10.3.0",
|
|
89
|
+
"tailwind-merge": "^3.5.0",
|
|
90
|
+
"tsup": "^8.5.1",
|
|
91
|
+
"typescript": "^5.9.0",
|
|
94
92
|
"vite": "^8.0.0",
|
|
95
93
|
"vitest": "^4.1.0"
|
|
96
94
|
},
|
|
@@ -33,11 +33,15 @@ export function AnimatedCounter({
|
|
|
33
33
|
const prevRef = useRef(value)
|
|
34
34
|
const rafRef = useRef<number | null>(null)
|
|
35
35
|
const [displayed, setDisplayed] = useState(value)
|
|
36
|
+
const decimalPlacesRef = useRef(
|
|
37
|
+
Number.isInteger(value) ? 0 : (value.toString().split('.')[1]?.length ?? 1)
|
|
38
|
+
)
|
|
36
39
|
|
|
37
40
|
useEffect(() => {
|
|
38
41
|
const from = prevRef.current
|
|
39
42
|
const to = value
|
|
40
43
|
prevRef.current = value
|
|
44
|
+
decimalPlacesRef.current = Number.isInteger(to) ? 0 : (to.toString().split('.')[1]?.length ?? 1)
|
|
41
45
|
|
|
42
46
|
if (reduced || from === to) {
|
|
43
47
|
setDisplayed(to)
|
|
@@ -72,11 +76,9 @@ export function AnimatedCounter({
|
|
|
72
76
|
|
|
73
77
|
const formatted = format
|
|
74
78
|
? format(displayed)
|
|
75
|
-
:
|
|
79
|
+
: decimalPlacesRef.current === 0
|
|
76
80
|
? Math.round(displayed).toString()
|
|
77
|
-
: displayed.toFixed(
|
|
78
|
-
value.toString().split('.')[1]?.length ?? 1
|
|
79
|
-
)
|
|
81
|
+
: displayed.toFixed(decimalPlacesRef.current)
|
|
80
82
|
|
|
81
83
|
return (
|
|
82
84
|
<span className={cn('tabular-nums', className)}>
|
|
@@ -88,6 +88,12 @@ function formatColor(hex: string, fmt: 'hex' | 'rgb' | 'hsl'): string {
|
|
|
88
88
|
return `hsl(${Math.round(h * 360)}, ${Math.round(s * 100)}%, ${Math.round(l * 100)}%)`
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
function isSafeColor(c: string): boolean {
|
|
92
|
+
return /^#[0-9a-f]{3,8}$/i.test(c) ||
|
|
93
|
+
/^rgba?\(\s*[\d.]+/.test(c) ||
|
|
94
|
+
/^hsla?\(\s*[\d.]+/.test(c)
|
|
95
|
+
}
|
|
96
|
+
|
|
91
97
|
const RECENT_COLORS_KEY = 'ui-kit-recent-colors'
|
|
92
98
|
const MAX_RECENT = 8
|
|
93
99
|
|
|
@@ -110,18 +116,24 @@ export function ColorInput({
|
|
|
110
116
|
className,
|
|
111
117
|
}: ColorInputProps): React.JSX.Element {
|
|
112
118
|
const prefersReducedMotion = useReducedMotion()
|
|
119
|
+
const isValidHex = /^#[0-9a-f]{3,8}$/i.test(value)
|
|
120
|
+
const safeValue = isValidHex ? value : '#000000'
|
|
113
121
|
const [open, setOpen] = useState(false)
|
|
114
122
|
const [copied, setCopied] = useState(false)
|
|
115
123
|
const [textInput, setTextInput] = useState('')
|
|
116
124
|
const [alpha, setAlpha] = useState(1)
|
|
117
125
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
118
126
|
const satAreaRef = useRef<HTMLDivElement>(null)
|
|
127
|
+
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
128
|
+
|
|
129
|
+
useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, [])
|
|
119
130
|
|
|
120
131
|
// Recent colors
|
|
121
132
|
const [recentColors, setRecentColors] = useState<string[]>(() => {
|
|
122
133
|
if (typeof window === 'undefined') return []
|
|
123
134
|
try {
|
|
124
|
-
|
|
135
|
+
const raw = JSON.parse(localStorage.getItem(RECENT_COLORS_KEY) ?? '[]')
|
|
136
|
+
return Array.isArray(raw) ? raw.filter((x: unknown) => typeof x === 'string' && x.length < 256).slice(0, MAX_RECENT) : []
|
|
125
137
|
} catch { return [] }
|
|
126
138
|
})
|
|
127
139
|
|
|
@@ -134,13 +146,13 @@ export function ColorInput({
|
|
|
134
146
|
}, [])
|
|
135
147
|
|
|
136
148
|
// HSL from current value
|
|
137
|
-
const { r, g, b } = useMemo(() => hexToRgb(
|
|
149
|
+
const { r, g, b } = useMemo(() => hexToRgb(safeValue), [safeValue])
|
|
138
150
|
const hsl = useMemo(() => rgbToHsl(r, g, b), [r, g, b])
|
|
139
151
|
|
|
140
152
|
// Sync text input
|
|
141
153
|
useEffect(() => {
|
|
142
|
-
setTextInput(formatColor(
|
|
143
|
-
}, [
|
|
154
|
+
setTextInput(formatColor(safeValue, format))
|
|
155
|
+
}, [safeValue, format])
|
|
144
156
|
|
|
145
157
|
// Close on click outside
|
|
146
158
|
useEffect(() => {
|
|
@@ -220,16 +232,17 @@ export function ColorInput({
|
|
|
220
232
|
return
|
|
221
233
|
}
|
|
222
234
|
// Revert
|
|
223
|
-
setTextInput(formatColor(
|
|
224
|
-
}, [textInput,
|
|
235
|
+
setTextInput(formatColor(safeValue, format))
|
|
236
|
+
}, [textInput, safeValue, format, onChange])
|
|
225
237
|
|
|
226
238
|
const handleCopy = useCallback(async () => {
|
|
227
239
|
try {
|
|
228
|
-
await navigator.clipboard.writeText(formatColor(
|
|
240
|
+
await navigator.clipboard.writeText(formatColor(safeValue, format))
|
|
229
241
|
setCopied(true)
|
|
230
|
-
|
|
242
|
+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
|
243
|
+
copyTimerRef.current = setTimeout(() => setCopied(false), 1500)
|
|
231
244
|
} catch { /* noop */ }
|
|
232
|
-
}, [
|
|
245
|
+
}, [safeValue, format])
|
|
233
246
|
|
|
234
247
|
// Position for sat/brightness marker
|
|
235
248
|
const markerX = hsl.s * 100
|
|
@@ -256,7 +269,7 @@ export function ColorInput({
|
|
|
256
269
|
>
|
|
257
270
|
<span
|
|
258
271
|
className="h-5 w-5 rounded-md border border-[hsl(var(--border-subtle))]"
|
|
259
|
-
style={{ backgroundColor:
|
|
272
|
+
style={{ backgroundColor: isSafeColor(safeValue) ? safeValue : undefined }}
|
|
260
273
|
/>
|
|
261
274
|
<span className="font-mono text-xs text-[hsl(var(--text-primary))]">
|
|
262
275
|
{formatColor(value, format)}
|
|
@@ -294,7 +307,7 @@ export function ColorInput({
|
|
|
294
307
|
style={{
|
|
295
308
|
left: `${markerX}%`,
|
|
296
309
|
top: `${Math.max(0, Math.min(100, markerY))}%`,
|
|
297
|
-
backgroundColor:
|
|
310
|
+
backgroundColor: isSafeColor(safeValue) ? safeValue : undefined,
|
|
298
311
|
}}
|
|
299
312
|
/>
|
|
300
313
|
</div>
|
|
@@ -325,7 +338,7 @@ export function ColorInput({
|
|
|
325
338
|
onChange={e => setAlpha(Number(e.target.value) / 100)}
|
|
326
339
|
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
|
327
340
|
style={{
|
|
328
|
-
background: `linear-gradient(to right, transparent, ${
|
|
341
|
+
background: `linear-gradient(to right, transparent, ${isSafeColor(safeValue) ? safeValue : '#000'})`,
|
|
329
342
|
}}
|
|
330
343
|
/>
|
|
331
344
|
</div>
|
|
@@ -376,7 +389,7 @@ export function ColorInput({
|
|
|
376
389
|
? 'border-[hsl(var(--brand-primary))] ring-2 ring-[hsl(var(--brand-primary)/0.3)] scale-110'
|
|
377
390
|
: 'border-[hsl(var(--border-subtle))] hover:scale-110',
|
|
378
391
|
)}
|
|
379
|
-
style={{ backgroundColor: color }}
|
|
392
|
+
style={{ backgroundColor: isSafeColor(color) ? color : undefined }}
|
|
380
393
|
title={color}
|
|
381
394
|
/>
|
|
382
395
|
))}
|
|
@@ -399,7 +412,7 @@ export function ColorInput({
|
|
|
399
412
|
'h-6 w-6 rounded-md border border-[hsl(var(--border-subtle))]',
|
|
400
413
|
'hover:scale-110 transition-transform',
|
|
401
414
|
)}
|
|
402
|
-
style={{ backgroundColor: color }}
|
|
415
|
+
style={{ backgroundColor: isSafeColor(color) ? color : undefined }}
|
|
403
416
|
title={color}
|
|
404
417
|
/>
|
|
405
418
|
))}
|
|
@@ -115,7 +115,8 @@ export function CommandBar({
|
|
|
115
115
|
const [recentIds, setRecentIds] = useState<string[]>(() => {
|
|
116
116
|
if (typeof window === 'undefined') return []
|
|
117
117
|
try {
|
|
118
|
-
|
|
118
|
+
const raw = JSON.parse(localStorage.getItem(recentKey) ?? '[]')
|
|
119
|
+
return Array.isArray(raw) ? raw.filter((x: unknown) => typeof x === 'string' && x.length < 256).slice(0, maxRecent) : []
|
|
119
120
|
} catch {
|
|
120
121
|
return []
|
|
121
122
|
}
|
|
@@ -48,6 +48,9 @@ export function CopyBlock({
|
|
|
48
48
|
const [isCollapsed, setIsCollapsed] = useState(true)
|
|
49
49
|
const [needsCollapse, setNeedsCollapse] = useState(false)
|
|
50
50
|
const contentRef = useRef<HTMLPreElement>(null)
|
|
51
|
+
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
52
|
+
|
|
53
|
+
useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, [])
|
|
51
54
|
|
|
52
55
|
// Check if content exceeds maxHeight
|
|
53
56
|
useEffect(() => {
|
|
@@ -61,19 +64,11 @@ export function CopyBlock({
|
|
|
61
64
|
try {
|
|
62
65
|
await navigator.clipboard.writeText(content)
|
|
63
66
|
setCopied(true)
|
|
64
|
-
|
|
67
|
+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
|
68
|
+
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
|
|
65
69
|
} catch {
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
textarea.value = content
|
|
69
|
-
textarea.style.position = 'fixed'
|
|
70
|
-
textarea.style.opacity = '0'
|
|
71
|
-
document.body.appendChild(textarea)
|
|
72
|
-
textarea.select()
|
|
73
|
-
document.execCommand('copy')
|
|
74
|
-
document.body.removeChild(textarea)
|
|
75
|
-
setCopied(true)
|
|
76
|
-
setTimeout(() => setCopied(false), 2000)
|
|
70
|
+
// Clipboard API not available
|
|
71
|
+
console.warn('Clipboard API not available')
|
|
77
72
|
}
|
|
78
73
|
}, [content])
|
|
79
74
|
|
|
@@ -19,6 +19,7 @@ import type { LucideIcon } from 'lucide-react'
|
|
|
19
19
|
import { TruncatedText } from './truncated-text'
|
|
20
20
|
import { EmptyState } from './empty-state'
|
|
21
21
|
import { Skeleton } from './skeleton'
|
|
22
|
+
import { Select } from './select'
|
|
22
23
|
import { cn } from '../utils'
|
|
23
24
|
|
|
24
25
|
// ---------------------------------------------------------------------------
|
|
@@ -679,18 +680,12 @@ export function DataTable<T>({
|
|
|
679
680
|
</span>
|
|
680
681
|
|
|
681
682
|
<div className="flex items-center gap-2">
|
|
682
|
-
<
|
|
683
|
-
value={pageSize}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
focus:border-[hsl(var(--brand-primary))] transition-colors"
|
|
689
|
-
>
|
|
690
|
-
{PAGE_SIZES.map(size => (
|
|
691
|
-
<option key={size} value={size}>{size} / page</option>
|
|
692
|
-
))}
|
|
693
|
-
</select>
|
|
683
|
+
<Select
|
|
684
|
+
value={String(pageSize)}
|
|
685
|
+
onValueChange={v => table.setPageSize(Number(v))}
|
|
686
|
+
options={PAGE_SIZES.map(size => ({ value: String(size), label: `${size} / page` }))}
|
|
687
|
+
className="w-[110px] text-[12px]"
|
|
688
|
+
/>
|
|
694
689
|
|
|
695
690
|
<div className="flex items-center gap-1">
|
|
696
691
|
<PaginationButton
|
|
@@ -27,8 +27,29 @@ interface DiffLine {
|
|
|
27
27
|
newLineNo?: number
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
const MAX_LINES = 2000
|
|
31
|
+
|
|
30
32
|
/** Simple line-by-line diff using longest common subsequence. */
|
|
31
33
|
function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] {
|
|
34
|
+
// Fall back to simple line-by-line comparison for large inputs
|
|
35
|
+
if (oldLines.length > MAX_LINES || newLines.length > MAX_LINES) {
|
|
36
|
+
const result: DiffLine[] = oldLines.map((l, i) => ({
|
|
37
|
+
type: (l === (newLines[i] ?? '') ? 'unchanged' : 'removed') as LineType,
|
|
38
|
+
content: l,
|
|
39
|
+
oldLineNo: i + 1,
|
|
40
|
+
newLineNo: i + 1,
|
|
41
|
+
}))
|
|
42
|
+
for (let i = oldLines.length; i < newLines.length; i++) {
|
|
43
|
+
result.push({
|
|
44
|
+
type: 'added' as LineType,
|
|
45
|
+
content: newLines[i],
|
|
46
|
+
oldLineNo: undefined,
|
|
47
|
+
newLineNo: i + 1,
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
// Build LCS table
|
|
33
54
|
const m = oldLines.length
|
|
34
55
|
const n = newLines.length
|
|
@@ -47,8 +47,13 @@ function toDateKey(d: Date): string {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
function parseDate(s: string): Date {
|
|
50
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
|
|
51
|
+
return new Date()
|
|
52
|
+
}
|
|
50
53
|
const [y, m, d] = s.split('-').map(Number)
|
|
51
|
-
|
|
54
|
+
const date = new Date(y, m - 1, d)
|
|
55
|
+
if (isNaN(date.getTime())) return new Date()
|
|
56
|
+
return date
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
|
@@ -28,6 +28,8 @@ export interface InfiniteScrollProps<T> {
|
|
|
28
28
|
itemHeight?: number
|
|
29
29
|
/** Content to display when items array is empty. */
|
|
30
30
|
emptyState?: React.ReactNode
|
|
31
|
+
/** Function to derive a stable key for each item. Falls back to index. */
|
|
32
|
+
getItemKey?: (item: T, index: number) => string | number
|
|
31
33
|
/** Additional class name for the scroll container. */
|
|
32
34
|
className?: string
|
|
33
35
|
}
|
|
@@ -49,6 +51,7 @@ export function InfiniteScroll<T>({
|
|
|
49
51
|
isLoading = false,
|
|
50
52
|
threshold = 200,
|
|
51
53
|
itemHeight,
|
|
54
|
+
getItemKey,
|
|
52
55
|
emptyState,
|
|
53
56
|
className,
|
|
54
57
|
}: InfiniteScrollProps<T>): React.JSX.Element {
|
|
@@ -57,6 +60,8 @@ export function InfiniteScroll<T>({
|
|
|
57
60
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
|
58
61
|
const [showScrollTop, setShowScrollTop] = useState(false)
|
|
59
62
|
const loadingRef = useRef(false)
|
|
63
|
+
const loadMoreRef = useRef(loadMore)
|
|
64
|
+
useEffect(() => { loadMoreRef.current = loadMore }, [loadMore])
|
|
60
65
|
|
|
61
66
|
// IntersectionObserver for infinite load trigger
|
|
62
67
|
useEffect(() => {
|
|
@@ -68,7 +73,7 @@ export function InfiniteScroll<T>({
|
|
|
68
73
|
const entry = entries[0]
|
|
69
74
|
if (entry?.isIntersecting && hasMore && !isLoading && !loadingRef.current) {
|
|
70
75
|
loadingRef.current = true
|
|
71
|
-
const result =
|
|
76
|
+
const result = loadMoreRef.current()
|
|
72
77
|
if (result && typeof result.then === 'function') {
|
|
73
78
|
result.then(() => { loadingRef.current = false }).catch(() => { loadingRef.current = false })
|
|
74
79
|
} else {
|
|
@@ -84,7 +89,7 @@ export function InfiniteScroll<T>({
|
|
|
84
89
|
|
|
85
90
|
observer.observe(sentinel)
|
|
86
91
|
return () => observer.disconnect()
|
|
87
|
-
}, [hasMore, isLoading,
|
|
92
|
+
}, [hasMore, isLoading, threshold])
|
|
88
93
|
|
|
89
94
|
// Update loadingRef when isLoading changes
|
|
90
95
|
useEffect(() => {
|
|
@@ -179,7 +184,7 @@ export function InfiniteScroll<T>({
|
|
|
179
184
|
) : (
|
|
180
185
|
/* Non-virtualized rendering */
|
|
181
186
|
items.map((item, index) => (
|
|
182
|
-
<div key={index}>
|
|
187
|
+
<div key={getItemKey ? getItemKey(item, index) : index}>
|
|
183
188
|
{renderItem(item, index)}
|
|
184
189
|
</div>
|
|
185
190
|
))
|
|
@@ -143,6 +143,8 @@ export function LiveFeed({
|
|
|
143
143
|
ref={scrollRef}
|
|
144
144
|
onScroll={handleScroll}
|
|
145
145
|
className="flex-1 overflow-y-auto"
|
|
146
|
+
aria-live="polite"
|
|
147
|
+
aria-atomic="false"
|
|
146
148
|
>
|
|
147
149
|
{visibleItems.length === 0 ? (
|
|
148
150
|
<div className="flex items-center justify-center py-12 text-sm text-[hsl(var(--text-tertiary))]">
|
|
@@ -53,6 +53,13 @@ const TYPE_ICON_COLOR: Record<Notification['type'], string> = {
|
|
|
53
53
|
error: 'text-[hsl(var(--status-critical))]',
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
const TYPE_PROGRESS_BG: Record<Notification['type'], string> = {
|
|
57
|
+
info: 'bg-[hsl(var(--brand-secondary))]',
|
|
58
|
+
success: 'bg-[hsl(var(--status-ok))]',
|
|
59
|
+
warning: 'bg-[hsl(var(--status-warning))]',
|
|
60
|
+
error: 'bg-[hsl(var(--status-critical))]',
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
const POSITION_CLASSES: Record<NonNullable<NotificationStackProps['position']>, string> = {
|
|
57
64
|
'top-right': 'top-4 right-4',
|
|
58
65
|
'top-left': 'top-4 left-4',
|
|
@@ -216,7 +223,7 @@ function NotificationCard({
|
|
|
216
223
|
{duration > 0 && (
|
|
217
224
|
<div className="h-0.5 bg-[hsl(var(--bg-overlay))]">
|
|
218
225
|
<div
|
|
219
|
-
className={cn('h-full transition-[width] duration-100',
|
|
226
|
+
className={cn('h-full transition-[width] duration-100', TYPE_PROGRESS_BG[type])}
|
|
220
227
|
style={{ width: `${progress}%` }}
|
|
221
228
|
/>
|
|
222
229
|
</div>
|
|
@@ -70,6 +70,8 @@ export function SortableList<T extends SortableItem>({
|
|
|
70
70
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
71
71
|
const startPos = useRef({ x: 0, y: 0 })
|
|
72
72
|
const dragItemId = useRef<string | null>(null)
|
|
73
|
+
const dragIdxRef = useRef<number | null>(null)
|
|
74
|
+
const overIdxRef = useRef<number | null>(null)
|
|
73
75
|
|
|
74
76
|
const handlePointerDown = useCallback(
|
|
75
77
|
(index: number) => (e: React.PointerEvent) => {
|
|
@@ -79,6 +81,8 @@ export function SortableList<T extends SortableItem>({
|
|
|
79
81
|
|
|
80
82
|
setDragIdx(index)
|
|
81
83
|
setOverIdx(index)
|
|
84
|
+
dragIdxRef.current = index
|
|
85
|
+
overIdxRef.current = index
|
|
82
86
|
dragItemId.current = items[index]?.id ?? null
|
|
83
87
|
startPos.current = { x: e.clientX, y: e.clientY }
|
|
84
88
|
|
|
@@ -97,6 +101,7 @@ export function SortableList<T extends SortableItem>({
|
|
|
97
101
|
: ev.clientX < midX + rect.width / 2 && ev.clientX > midX - rect.width / 2
|
|
98
102
|
|
|
99
103
|
if (isOver) {
|
|
104
|
+
overIdxRef.current = i
|
|
100
105
|
setOverIdx(i)
|
|
101
106
|
break
|
|
102
107
|
}
|
|
@@ -107,19 +112,18 @@ export function SortableList<T extends SortableItem>({
|
|
|
107
112
|
document.removeEventListener('pointermove', handlePointerMove)
|
|
108
113
|
document.removeEventListener('pointerup', handlePointerUp)
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
})
|
|
115
|
+
const prev = dragIdxRef.current
|
|
116
|
+
const over = overIdxRef.current
|
|
117
|
+
if (prev !== null && over !== null && prev !== over) {
|
|
118
|
+
const newItems = [...items]
|
|
119
|
+
const [moved] = newItems.splice(prev, 1)
|
|
120
|
+
if (moved) newItems.splice(over, 0, moved)
|
|
121
|
+
onReorder(newItems)
|
|
122
|
+
}
|
|
123
|
+
dragIdxRef.current = null
|
|
124
|
+
overIdxRef.current = null
|
|
125
|
+
setDragIdx(null)
|
|
126
|
+
setOverIdx(null)
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
document.addEventListener('pointermove', handlePointerMove)
|
|
@@ -97,6 +97,8 @@ export function StepWizard({
|
|
|
97
97
|
const [direction, setDirection] = useState(1) // 1=forward, -1=backward
|
|
98
98
|
const [isComplete, setIsComplete] = useState(false)
|
|
99
99
|
const contentRef = useRef<HTMLDivElement>(null)
|
|
100
|
+
const wizardRef = useRef<HTMLDivElement>(null)
|
|
101
|
+
const validatingRef = useRef(false)
|
|
100
102
|
|
|
101
103
|
// Save state to sessionStorage
|
|
102
104
|
useEffect(() => {
|
|
@@ -118,20 +120,25 @@ export function StepWizard({
|
|
|
118
120
|
}, [currentStep, onStepChange])
|
|
119
121
|
|
|
120
122
|
const handleNext = useCallback(async () => {
|
|
123
|
+
if (validatingRef.current) return
|
|
121
124
|
const step = steps[currentStep]
|
|
122
125
|
if (step?.validate) {
|
|
126
|
+
validatingRef.current = true
|
|
123
127
|
setIsValidating(true)
|
|
124
128
|
try {
|
|
125
129
|
const valid = await step.validate()
|
|
126
130
|
if (!valid) {
|
|
127
131
|
setIsValidating(false)
|
|
132
|
+
validatingRef.current = false
|
|
128
133
|
return
|
|
129
134
|
}
|
|
130
135
|
} catch {
|
|
131
136
|
setIsValidating(false)
|
|
137
|
+
validatingRef.current = false
|
|
132
138
|
return
|
|
133
139
|
}
|
|
134
140
|
setIsValidating(false)
|
|
141
|
+
validatingRef.current = false
|
|
135
142
|
}
|
|
136
143
|
|
|
137
144
|
setCompleted(prev => new Set(prev).add(currentStep))
|
|
@@ -165,6 +172,7 @@ export function StepWizard({
|
|
|
165
172
|
// Keyboard navigation
|
|
166
173
|
useEffect(() => {
|
|
167
174
|
const handler = (e: KeyboardEvent) => {
|
|
175
|
+
if (!wizardRef.current?.contains(e.target as Node)) return
|
|
168
176
|
if (e.key === 'Enter' && !e.shiftKey && !(e.target instanceof HTMLTextAreaElement)) {
|
|
169
177
|
handleNext()
|
|
170
178
|
}
|
|
@@ -188,7 +196,7 @@ export function StepWizard({
|
|
|
188
196
|
const isHorizontal = orientation === 'horizontal'
|
|
189
197
|
|
|
190
198
|
return (
|
|
191
|
-
<div className={cn('flex flex-col', className)}>
|
|
199
|
+
<div ref={wizardRef} className={cn('flex flex-col', className)}>
|
|
192
200
|
{/* Step indicator */}
|
|
193
201
|
<div className={cn(
|
|
194
202
|
'mb-6',
|
|
@@ -73,6 +73,9 @@ export function StreamingText({
|
|
|
73
73
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
74
74
|
const prevStreamingRef = useRef(isStreaming)
|
|
75
75
|
const [copied, setCopied] = useState(false)
|
|
76
|
+
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
77
|
+
|
|
78
|
+
useEffect(() => () => { if (copyTimerRef.current) clearTimeout(copyTimerRef.current) }, [])
|
|
76
79
|
|
|
77
80
|
useEffect(() => {
|
|
78
81
|
if (prevStreamingRef.current && !isStreaming) {
|
|
@@ -91,7 +94,8 @@ export function StreamingText({
|
|
|
91
94
|
const handleCopy = useCallback(() => {
|
|
92
95
|
void navigator.clipboard.writeText(text).then(() => {
|
|
93
96
|
setCopied(true)
|
|
94
|
-
|
|
97
|
+
if (copyTimerRef.current) clearTimeout(copyTimerRef.current)
|
|
98
|
+
copyTimerRef.current = setTimeout(() => setCopied(false), 2000)
|
|
95
99
|
})
|
|
96
100
|
}, [text])
|
|
97
101
|
|
|
@@ -114,6 +118,7 @@ export function StreamingText({
|
|
|
114
118
|
animation: `streaming-cursor-blink ${speed}ms step-end infinite`,
|
|
115
119
|
}
|
|
116
120
|
}
|
|
121
|
+
aria-hidden="true"
|
|
117
122
|
/>
|
|
118
123
|
)}
|
|
119
124
|
<AnimatePresence>
|
|
@@ -150,14 +155,6 @@ export function StreamingText({
|
|
|
150
155
|
)}
|
|
151
156
|
</AnimatePresence>
|
|
152
157
|
|
|
153
|
-
{showCursor && isStreaming && !reduced && (
|
|
154
|
-
<style>{`
|
|
155
|
-
@keyframes streaming-cursor-blink {
|
|
156
|
-
0%, 100% { opacity: 1; }
|
|
157
|
-
50% { opacity: 0; }
|
|
158
|
-
}
|
|
159
|
-
`}</style>
|
|
160
|
-
)}
|
|
161
158
|
</div>
|
|
162
159
|
)
|
|
163
160
|
}
|
package/src/theme.css
CHANGED
|
@@ -168,6 +168,12 @@ html.light {
|
|
|
168
168
|
color: hsl(var(--text-primary));
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
/* ── Streaming cursor ────────────────────────────────────────────────────── */
|
|
172
|
+
@keyframes streaming-cursor-blink {
|
|
173
|
+
0%, 100% { opacity: 1; }
|
|
174
|
+
50% { opacity: 0; }
|
|
175
|
+
}
|
|
176
|
+
|
|
171
177
|
/* ── Reduced motion ──────────────────────────────────────────────────────── */
|
|
172
178
|
@media (prefers-reduced-motion: reduce) {
|
|
173
179
|
.animate-pulse-ring,
|