@w5-ui/react 0.1.0

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 (164) hide show
  1. package/README.md +9 -0
  2. package/package.json +59 -0
  3. package/src/components/Accordion.tsx +65 -0
  4. package/src/components/ActionMenu.tsx +131 -0
  5. package/src/components/ActivityFeed.tsx +77 -0
  6. package/src/components/Alert.tsx +33 -0
  7. package/src/components/AnnotatedTimeSeries.tsx +148 -0
  8. package/src/components/AppShell.tsx +158 -0
  9. package/src/components/ArcDiagram.tsx +114 -0
  10. package/src/components/AreaChart.tsx +101 -0
  11. package/src/components/Avatar.tsx +37 -0
  12. package/src/components/AvatarGroup.tsx +36 -0
  13. package/src/components/Badge.tsx +36 -0
  14. package/src/components/Banner.tsx +49 -0
  15. package/src/components/BarChart.tsx +106 -0
  16. package/src/components/BeeswarmChart.tsx +142 -0
  17. package/src/components/BoxPlot.tsx +142 -0
  18. package/src/components/Breadcrumb.tsx +44 -0
  19. package/src/components/BubbleChart.tsx +115 -0
  20. package/src/components/BulletChart.tsx +121 -0
  21. package/src/components/BumpChart.tsx +127 -0
  22. package/src/components/Button.tsx +61 -0
  23. package/src/components/CIDRInput.tsx +191 -0
  24. package/src/components/CalendarHeatmap.tsx +117 -0
  25. package/src/components/Callout.tsx +82 -0
  26. package/src/components/CandlestickChart.tsx +113 -0
  27. package/src/components/Card.tsx +27 -0
  28. package/src/components/Carousel.tsx +78 -0
  29. package/src/components/Checkbox.tsx +42 -0
  30. package/src/components/ChordDiagram.tsx +133 -0
  31. package/src/components/CirclePacking.tsx +189 -0
  32. package/src/components/CodeBlock.tsx +27 -0
  33. package/src/components/ColorPicker.tsx +120 -0
  34. package/src/components/ColumnToggle.tsx +106 -0
  35. package/src/components/Combobox.tsx +211 -0
  36. package/src/components/CommandPalette.tsx +168 -0
  37. package/src/components/ConfirmDialog.tsx +114 -0
  38. package/src/components/ContextMenu.tsx +127 -0
  39. package/src/components/ContextualHelp.tsx +69 -0
  40. package/src/components/ContourPlot.tsx +255 -0
  41. package/src/components/CopyButton.tsx +47 -0
  42. package/src/components/CorrelationMatrix.tsx +107 -0
  43. package/src/components/CountUp.tsx +70 -0
  44. package/src/components/CumulativeDistribution.tsx +155 -0
  45. package/src/components/DatePicker.tsx +315 -0
  46. package/src/components/DateRangePicker.tsx +327 -0
  47. package/src/components/Dendrogram.tsx +150 -0
  48. package/src/components/Donut.tsx +101 -0
  49. package/src/components/DotPlot.tsx +195 -0
  50. package/src/components/Drawer.tsx +81 -0
  51. package/src/components/DualAxisChart.tsx +209 -0
  52. package/src/components/DumbbellChart.tsx +102 -0
  53. package/src/components/DurationInput.tsx +144 -0
  54. package/src/components/EmptyState.tsx +40 -0
  55. package/src/components/ErrorBandChart.tsx +124 -0
  56. package/src/components/ExpandableRow.tsx +58 -0
  57. package/src/components/FileUpload.tsx +129 -0
  58. package/src/components/FilterBuilder.tsx +217 -0
  59. package/src/components/FlameGraph.tsx +119 -0
  60. package/src/components/ForceGraph.tsx +149 -0
  61. package/src/components/FunnelChart.tsx +79 -0
  62. package/src/components/GanttChart.tsx +96 -0
  63. package/src/components/Gauge.tsx +71 -0
  64. package/src/components/GroupedList.tsx +125 -0
  65. package/src/components/HealthBar.tsx +25 -0
  66. package/src/components/HeatMap.tsx +106 -0
  67. package/src/components/HexbinChart.tsx +152 -0
  68. package/src/components/Histogram.tsx +121 -0
  69. package/src/components/HorizonChart.tsx +110 -0
  70. package/src/components/HoverCard.tsx +52 -0
  71. package/src/components/IPInput.tsx +147 -0
  72. package/src/components/IcicleChart.tsx +118 -0
  73. package/src/components/InlineEdit.tsx +119 -0
  74. package/src/components/Input.tsx +94 -0
  75. package/src/components/InputGroup.tsx +21 -0
  76. package/src/components/JsonViewer.tsx +116 -0
  77. package/src/components/KVTable.tsx +29 -0
  78. package/src/components/KanbanBoard.tsx +171 -0
  79. package/src/components/Kbd.tsx +22 -0
  80. package/src/components/LineChart.tsx +112 -0
  81. package/src/components/LogViewer.tsx +102 -0
  82. package/src/components/LollipopChart.tsx +110 -0
  83. package/src/components/MACInput.tsx +164 -0
  84. package/src/components/MarimekkoChart.tsx +143 -0
  85. package/src/components/MatrixChart.tsx +118 -0
  86. package/src/components/Menubar.tsx +194 -0
  87. package/src/components/MirroredBarChart.tsx +148 -0
  88. package/src/components/Modal.tsx +87 -0
  89. package/src/components/MultiSelect.tsx +232 -0
  90. package/src/components/NightingaleChart.tsx +101 -0
  91. package/src/components/NotificationPanel.tsx +187 -0
  92. package/src/components/NumberInput.tsx +129 -0
  93. package/src/components/OTPInput.tsx +135 -0
  94. package/src/components/Pagination.tsx +73 -0
  95. package/src/components/ParallelCoordinates.tsx +149 -0
  96. package/src/components/ParetoChart.tsx +159 -0
  97. package/src/components/PasswordInput.tsx +112 -0
  98. package/src/components/PieChart.tsx +109 -0
  99. package/src/components/Pill.tsx +26 -0
  100. package/src/components/PolarHeatmap.tsx +118 -0
  101. package/src/components/Popover.tsx +103 -0
  102. package/src/components/ProgressBar.tsx +33 -0
  103. package/src/components/PunchCard.tsx +93 -0
  104. package/src/components/QuadrantChart.tsx +256 -0
  105. package/src/components/RadarChart.tsx +119 -0
  106. package/src/components/RadialBarChart.tsx +103 -0
  107. package/src/components/RadioGroup.tsx +69 -0
  108. package/src/components/RangeSlider.tsx +116 -0
  109. package/src/components/RankedList.tsx +61 -0
  110. package/src/components/ResizablePanel.tsx +122 -0
  111. package/src/components/RidgelinePlot.tsx +155 -0
  112. package/src/components/SankeyDiagram.tsx +156 -0
  113. package/src/components/ScatterPlot.tsx +96 -0
  114. package/src/components/SegmentedControl.tsx +78 -0
  115. package/src/components/Select.tsx +158 -0
  116. package/src/components/SelectionToolbar.tsx +55 -0
  117. package/src/components/Signal.tsx +19 -0
  118. package/src/components/Skeleton.tsx +37 -0
  119. package/src/components/SkipLink.tsx +19 -0
  120. package/src/components/Slider.tsx +74 -0
  121. package/src/components/SlopeChart.tsx +205 -0
  122. package/src/components/SortableList.tsx +130 -0
  123. package/src/components/SortableTable.tsx +46 -0
  124. package/src/components/Sparkline.tsx +50 -0
  125. package/src/components/SparklineMatrix.tsx +117 -0
  126. package/src/components/Spinner.tsx +8 -0
  127. package/src/components/SplitButton.tsx +151 -0
  128. package/src/components/Spoiler.tsx +47 -0
  129. package/src/components/StackedBarChart.tsx +145 -0
  130. package/src/components/StackedProgress.tsx +47 -0
  131. package/src/components/StarRating.tsx +103 -0
  132. package/src/components/Stat.tsx +48 -0
  133. package/src/components/StatusIndicator.tsx +26 -0
  134. package/src/components/StepChart.tsx +130 -0
  135. package/src/components/Stepper.tsx +51 -0
  136. package/src/components/StreamGraph.tsx +97 -0
  137. package/src/components/StripeChart.tsx +76 -0
  138. package/src/components/SunburstChart.tsx +142 -0
  139. package/src/components/SwitchPortGrid.tsx +59 -0
  140. package/src/components/Tabs.tsx +91 -0
  141. package/src/components/Tag.tsx +37 -0
  142. package/src/components/TagInput.tsx +99 -0
  143. package/src/components/Textarea.tsx +9 -0
  144. package/src/components/ThresholdAreaChart.tsx +113 -0
  145. package/src/components/TimePicker.tsx +144 -0
  146. package/src/components/TimeRange.tsx +152 -0
  147. package/src/components/Timeline.tsx +31 -0
  148. package/src/components/Toast.tsx +72 -0
  149. package/src/components/Toggle.tsx +68 -0
  150. package/src/components/ToggleGroup.tsx +57 -0
  151. package/src/components/Tooltip.tsx +25 -0
  152. package/src/components/TransferList.tsx +227 -0
  153. package/src/components/TreeMap.tsx +126 -0
  154. package/src/components/TreeView.tsx +221 -0
  155. package/src/components/UptimeTimeline.tsx +138 -0
  156. package/src/components/VennDiagram.tsx +151 -0
  157. package/src/components/ViolinPlot.tsx +158 -0
  158. package/src/components/VirtualList.tsx +59 -0
  159. package/src/components/WaffleChart.tsx +79 -0
  160. package/src/components/WaterfallChart.tsx +164 -0
  161. package/src/components/WordCloud.tsx +107 -0
  162. package/src/icons.tsx +241 -0
  163. package/src/index.ts +160 -0
  164. package/src/styles.css +2 -0
@@ -0,0 +1,187 @@
1
+ import { useEffect, useId, useRef, useState } from 'react'
2
+ import { CloseIcon } from '../icons.js'
3
+ import { IconButton } from './Button.js'
4
+
5
+ export type NotifType = 'alarm' | 'system' | 'update'
6
+ export type NotifSeverity = 'danger' | 'warn' | 'info' | 'success'
7
+
8
+ export interface Notification {
9
+ id: string
10
+ type: NotifType
11
+ severity?: NotifSeverity
12
+ title: string
13
+ description?: string
14
+ time: string
15
+ read: boolean
16
+ }
17
+
18
+ export interface NotificationPanelProps {
19
+ open: boolean
20
+ onClose: () => void
21
+ notifications: Notification[]
22
+ onMarkRead?: (id: string) => void
23
+ onMarkAllRead?: () => void
24
+ }
25
+
26
+ const FOCUSABLE =
27
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
28
+
29
+ const SEV_COLOR: Record<string, string> = {
30
+ danger: '#F03A3A',
31
+ warn: '#F5A623',
32
+ info: '#006FFF',
33
+ success: '#00B070',
34
+ }
35
+
36
+ const TYPE_LABEL: Record<NotifType, string> = {
37
+ alarm: 'Alarm',
38
+ system: 'System',
39
+ update: 'Update',
40
+ }
41
+
42
+ export function NotificationPanel({
43
+ open,
44
+ onClose,
45
+ notifications,
46
+ onMarkRead,
47
+ onMarkAllRead,
48
+ }: NotificationPanelProps) {
49
+ const titleId = useId()
50
+ const panelRef = useRef<HTMLDivElement>(null)
51
+ const [filter, setFilter] = useState<'all' | NotifType>('all')
52
+
53
+ const unread = notifications.filter((n) => !n.read).length
54
+ const filtered = filter === 'all' ? notifications : notifications.filter((n) => n.type === filter)
55
+
56
+ useEffect(() => {
57
+ if (!open) return
58
+ const prev = document.activeElement as HTMLElement | null
59
+ const panel = panelRef.current
60
+ const first = panel?.querySelector<HTMLElement>(FOCUSABLE)
61
+ ;(first ?? panel)?.focus()
62
+
63
+ function onKey(e: KeyboardEvent) {
64
+ if (e.key === 'Escape') {
65
+ onClose()
66
+ return
67
+ }
68
+ if (e.key !== 'Tab' || !panel) return
69
+ const items = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
70
+ if (!items.length) {
71
+ e.preventDefault()
72
+ return
73
+ }
74
+ const f = items[0]!,
75
+ l = items[items.length - 1]!
76
+ const active = document.activeElement
77
+ if (e.shiftKey && active === f) {
78
+ e.preventDefault()
79
+ l.focus()
80
+ } else if (!e.shiftKey && active === l) {
81
+ e.preventDefault()
82
+ f.focus()
83
+ }
84
+ }
85
+
86
+ window.addEventListener('keydown', onKey)
87
+ return () => {
88
+ window.removeEventListener('keydown', onKey)
89
+ prev?.focus?.()
90
+ }
91
+ }, [open, onClose])
92
+
93
+ return (
94
+ <>
95
+ <div
96
+ className={`np-overlay${open ? ' np-overlay--show' : ''}`}
97
+ onClick={onClose}
98
+ aria-hidden="true"
99
+ />
100
+ <div
101
+ ref={panelRef}
102
+ className={`np-panel${open ? ' np-panel--show' : ''}`}
103
+ role="dialog"
104
+ aria-modal="true"
105
+ aria-labelledby={titleId}
106
+ tabIndex={-1}
107
+ >
108
+ <div className="np-header">
109
+ <div className="np-title-row">
110
+ <h2 id={titleId} className="np-title">
111
+ Notifications
112
+ {unread > 0 && (
113
+ <span className="np-badge" aria-label={`${unread} unread`}>
114
+ {unread}
115
+ </span>
116
+ )}
117
+ </h2>
118
+ <div className="np-header-actions">
119
+ {unread > 0 && (
120
+ <button type="button" className="np-mark-all" onClick={onMarkAllRead}>
121
+ Mark all read
122
+ </button>
123
+ )}
124
+ <IconButton onClick={onClose} aria-label="Close notifications">
125
+ <CloseIcon />
126
+ </IconButton>
127
+ </div>
128
+ </div>
129
+ <div className="np-filters" role="tablist" aria-label="Filter by type">
130
+ {(['all', 'alarm', 'system', 'update'] as const).map((f) => (
131
+ <button
132
+ key={f}
133
+ type="button"
134
+ role="tab"
135
+ aria-selected={filter === f}
136
+ className={`np-filter${filter === f ? ' np-filter--active' : ''}`}
137
+ onClick={() => setFilter(f)}
138
+ >
139
+ {f === 'all' ? 'All' : TYPE_LABEL[f]}
140
+ </button>
141
+ ))}
142
+ </div>
143
+ </div>
144
+ <div
145
+ className="np-list"
146
+ role="log"
147
+ aria-live="polite"
148
+ aria-label="Notifications"
149
+ aria-relevant="additions"
150
+ >
151
+ {filtered.length === 0 ? (
152
+ <div className="np-empty">No notifications</div>
153
+ ) : (
154
+ filtered.map((n) => (
155
+ <div key={n.id} className={`np-item${n.read ? ' np-item--read' : ''}`}>
156
+ <span
157
+ className="np-dot"
158
+ style={{ background: SEV_COLOR[n.severity ?? 'info'] }}
159
+ aria-hidden="true"
160
+ />
161
+ <div className="np-item-body">
162
+ <div className="np-item-top">
163
+ <span className="np-item-title">{n.title}</span>
164
+ <span className="np-item-time">{n.time}</span>
165
+ </div>
166
+ {n.description && <p className="np-item-desc">{n.description}</p>}
167
+ <span className="np-type-badge">{TYPE_LABEL[n.type]}</span>
168
+ </div>
169
+ {!n.read && onMarkRead && (
170
+ <button
171
+ type="button"
172
+ className="np-read-btn"
173
+ onClick={() => onMarkRead(n.id)}
174
+ aria-label={`Mark as read: ${n.title}`}
175
+ title="Mark as read"
176
+ >
177
+ ✓
178
+ </button>
179
+ )}
180
+ </div>
181
+ ))
182
+ )}
183
+ </div>
184
+ </div>
185
+ </>
186
+ )
187
+ }
@@ -0,0 +1,129 @@
1
+ import { useState, useId } from 'react'
2
+ import type { KeyboardEvent, ChangeEvent } from 'react'
3
+
4
+ export interface NumberInputProps {
5
+ label?: string
6
+ value?: number
7
+ defaultValue?: number
8
+ min?: number
9
+ max?: number
10
+ step?: number
11
+ suffix?: string
12
+ disabled?: boolean
13
+ onChange?: (value: number) => void
14
+ id?: string
15
+ className?: string
16
+ }
17
+
18
+ export function NumberInput({
19
+ label,
20
+ value: valueProp,
21
+ defaultValue = 0,
22
+ min,
23
+ max,
24
+ step = 1,
25
+ suffix,
26
+ disabled = false,
27
+ onChange,
28
+ id,
29
+ className = '',
30
+ }: NumberInputProps) {
31
+ const generatedId = useId()
32
+ const inputId = id ?? generatedId
33
+ const [internalValue, setInternalValue] = useState(defaultValue)
34
+ const value = valueProp !== undefined ? valueProp : internalValue
35
+
36
+ function clamp(n: number) {
37
+ let v = n
38
+ if (min !== undefined) v = Math.max(min, v)
39
+ if (max !== undefined) v = Math.min(max, v)
40
+ return v
41
+ }
42
+
43
+ function commit(next: number) {
44
+ const clamped = clamp(next)
45
+ if (valueProp === undefined) setInternalValue(clamped)
46
+ onChange?.(clamped)
47
+ }
48
+
49
+ function handleKey(e: KeyboardEvent<HTMLInputElement>) {
50
+ if (e.key === 'ArrowUp') {
51
+ e.preventDefault()
52
+ commit(value + step)
53
+ } else if (e.key === 'ArrowDown') {
54
+ e.preventDefault()
55
+ commit(value - step)
56
+ } else if (e.key === 'PageUp') {
57
+ e.preventDefault()
58
+ commit(value + step * 10)
59
+ } else if (e.key === 'PageDown') {
60
+ e.preventDefault()
61
+ commit(value - step * 10)
62
+ } else if (e.key === 'Home' && min !== undefined) {
63
+ e.preventDefault()
64
+ commit(min)
65
+ } else if (e.key === 'End' && max !== undefined) {
66
+ e.preventDefault()
67
+ commit(max)
68
+ }
69
+ }
70
+
71
+ function handleChange(e: ChangeEvent<HTMLInputElement>) {
72
+ const n = Number(e.target.value)
73
+ if (!isNaN(n)) commit(n)
74
+ }
75
+
76
+ const canDecrement = !disabled && (min === undefined || value > min)
77
+ const canIncrement = !disabled && (max === undefined || value < max)
78
+
79
+ return (
80
+ <div className={`number-input ${className}`.trim()}>
81
+ {label && (
82
+ <label htmlFor={inputId} className="number-input__label">
83
+ {label}
84
+ </label>
85
+ )}
86
+ <div className="number-input__control">
87
+ <button
88
+ type="button"
89
+ className="number-input__btn"
90
+ aria-label="Decrement"
91
+ tabIndex={-1}
92
+ disabled={!canDecrement}
93
+ onClick={() => commit(value - step)}
94
+ >
95
+ −
96
+ </button>
97
+ <input
98
+ id={inputId}
99
+ type="number"
100
+ role="spinbutton"
101
+ aria-valuenow={value}
102
+ aria-valuemin={min}
103
+ aria-valuemax={max}
104
+ aria-valuetext={suffix ? `${value} ${suffix}` : undefined}
105
+ value={value}
106
+ disabled={disabled}
107
+ className="number-input__field"
108
+ onChange={handleChange}
109
+ onKeyDown={handleKey}
110
+ />
111
+ {suffix && (
112
+ <span className="number-input__suffix" aria-hidden="true">
113
+ {suffix}
114
+ </span>
115
+ )}
116
+ <button
117
+ type="button"
118
+ className="number-input__btn"
119
+ aria-label="Increment"
120
+ tabIndex={-1}
121
+ disabled={!canIncrement}
122
+ onClick={() => commit(value + step)}
123
+ >
124
+ +
125
+ </button>
126
+ </div>
127
+ </div>
128
+ )
129
+ }
@@ -0,0 +1,135 @@
1
+ import { useId, useRef, useState } from 'react'
2
+ import type { ClipboardEvent, KeyboardEvent } from 'react'
3
+
4
+ export interface OTPInputProps {
5
+ label?: string
6
+ length?: number
7
+ value?: string
8
+ defaultValue?: string
9
+ disabled?: boolean
10
+ onChange?: (value: string) => void
11
+ id?: string
12
+ className?: string
13
+ }
14
+
15
+ export function OTPInput({
16
+ label,
17
+ length = 6,
18
+ value: valueProp,
19
+ defaultValue = '',
20
+ disabled = false,
21
+ onChange,
22
+ id,
23
+ className = '',
24
+ }: OTPInputProps) {
25
+ const gid = useId()
26
+ const inputId = id ?? gid
27
+ const [internal, setInternal] = useState<string[]>(() => {
28
+ const src = valueProp ?? defaultValue
29
+ return Array.from({ length }, (_, i) => src[i] ?? '')
30
+ })
31
+ const refs = useRef<(HTMLInputElement | null)[]>(Array(length).fill(null))
32
+
33
+ const digits =
34
+ valueProp !== undefined ? Array.from({ length }, (_, i) => valueProp[i] ?? '') : internal
35
+
36
+ function focusAt(i: number) {
37
+ const el = refs.current[i]
38
+ if (el) {
39
+ el.focus()
40
+ el.select()
41
+ }
42
+ }
43
+
44
+ function commit(next: string[]) {
45
+ if (valueProp === undefined) setInternal(next)
46
+ onChange?.(next.join(''))
47
+ }
48
+
49
+ function handleInput(i: number, raw: string) {
50
+ const char = raw.replace(/\D/g, '').slice(-1)
51
+ const next = [...digits]
52
+ next[i] = char
53
+ commit(next)
54
+ if (char && i < length - 1) focusAt(i + 1)
55
+ }
56
+
57
+ function handleKey(i: number, e: KeyboardEvent<HTMLInputElement>) {
58
+ if (e.key === 'Backspace') {
59
+ if (digits[i]) {
60
+ const next = [...digits]
61
+ next[i] = ''
62
+ commit(next)
63
+ } else if (i > 0) {
64
+ e.preventDefault()
65
+ focusAt(i - 1)
66
+ }
67
+ } else if (e.key === 'ArrowLeft' && i > 0) {
68
+ e.preventDefault()
69
+ focusAt(i - 1)
70
+ } else if (e.key === 'ArrowRight' && i < length - 1) {
71
+ e.preventDefault()
72
+ focusAt(i + 1)
73
+ }
74
+ }
75
+
76
+ function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
77
+ const text = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, length)
78
+ if (!text.length) return
79
+ e.preventDefault()
80
+ const next = Array.from({ length }, (_, i) => text[i] ?? digits[i] ?? '')
81
+ commit(next)
82
+ focusAt(Math.min(text.length, length - 1))
83
+ }
84
+
85
+ const half = Math.floor(length / 2)
86
+
87
+ return (
88
+ <div className={`otp-input-wrapper${className ? ` ${className}` : ''}`}>
89
+ {label && (
90
+ <label htmlFor={`${inputId}-0`} className="otp-input__label">
91
+ {label}
92
+ </label>
93
+ )}
94
+ <div
95
+ className={`otp-input${disabled ? ' otp-input--disabled' : ''}`}
96
+ role="group"
97
+ aria-label={label ?? 'One-time password'}
98
+ >
99
+ {digits.flatMap((d, i) => {
100
+ const nodes = []
101
+ if (i === half) {
102
+ nodes.push(
103
+ <span key="sep" className="otp-input__separator" aria-hidden="true">
104
+ &ndash;
105
+ </span>,
106
+ )
107
+ }
108
+ nodes.push(
109
+ <input
110
+ key={i}
111
+ ref={(el) => {
112
+ refs.current[i] = el
113
+ }}
114
+ id={i === 0 ? `${inputId}-0` : undefined}
115
+ type="text"
116
+ inputMode="numeric"
117
+ pattern="[0-9]*"
118
+ maxLength={1}
119
+ aria-label={`Digit ${i + 1} of ${length}`}
120
+ value={d}
121
+ disabled={disabled}
122
+ className="otp-input__digit"
123
+ autoComplete={i === 0 ? 'one-time-code' : 'off'}
124
+ onChange={(e) => handleInput(i, e.target.value)}
125
+ onKeyDown={(e) => handleKey(i, e)}
126
+ onPaste={handlePaste}
127
+ onFocus={(e) => e.currentTarget.select()}
128
+ />,
129
+ )
130
+ return nodes
131
+ })}
132
+ </div>
133
+ </div>
134
+ )
135
+ }
@@ -0,0 +1,73 @@
1
+ import type { HTMLAttributes } from 'react'
2
+
3
+ export interface PaginationProps extends Omit<HTMLAttributes<HTMLElement>, 'onChange'> {
4
+ page: number
5
+ pageSize: number
6
+ total: number
7
+ onChange: (page: number) => void
8
+ }
9
+
10
+ function pageNumbers(page: number, totalPages: number): (number | '...')[] {
11
+ if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1)
12
+ const items: (number | '...')[] = [1]
13
+ const lo = Math.max(2, page - 1)
14
+ const hi = Math.min(totalPages - 1, page + 1)
15
+ if (lo > 2) items.push('...')
16
+ for (let i = lo; i <= hi; i++) items.push(i)
17
+ if (hi < totalPages - 1) items.push('...')
18
+ items.push(totalPages)
19
+ return items
20
+ }
21
+
22
+ export function Pagination({
23
+ page,
24
+ pageSize,
25
+ total,
26
+ onChange,
27
+ className = '',
28
+ ...rest
29
+ }: PaginationProps) {
30
+ const totalPages = Math.max(1, Math.ceil(total / pageSize))
31
+ if (totalPages <= 1) return null
32
+ const pages = pageNumbers(page, totalPages)
33
+ return (
34
+ <nav className={`pagination ${className}`.trim()} aria-label="Pagination" {...rest}>
35
+ <button
36
+ type="button"
37
+ className="pagination-btn"
38
+ aria-label="Previous page"
39
+ disabled={page <= 1}
40
+ onClick={() => onChange(page - 1)}
41
+ >
42
+ ‹
43
+ </button>
44
+ {pages.map((p, i) =>
45
+ p === '...' ? (
46
+ <span key={`e${i}`} className="pagination-ellipsis" aria-hidden="true">
47
+ …
48
+ </span>
49
+ ) : (
50
+ <button
51
+ key={p}
52
+ type="button"
53
+ className={`pagination-btn${p === page ? ' active' : ''}`}
54
+ aria-label={`Page ${p}`}
55
+ aria-current={p === page ? 'page' : undefined}
56
+ onClick={() => onChange(p)}
57
+ >
58
+ {p}
59
+ </button>
60
+ ),
61
+ )}
62
+ <button
63
+ type="button"
64
+ className="pagination-btn"
65
+ aria-label="Next page"
66
+ disabled={page >= totalPages}
67
+ onClick={() => onChange(page + 1)}
68
+ >
69
+ ›
70
+ </button>
71
+ </nav>
72
+ )
73
+ }
@@ -0,0 +1,149 @@
1
+ const COLORS = ['#006FFF', '#00C8C8', '#F5A623', '#7FB6FF', '#A878F5', '#F56342']
2
+
3
+ export interface ParallelAxis {
4
+ label: string
5
+ min: number
6
+ max: number
7
+ unit?: string
8
+ invert?: boolean
9
+ }
10
+
11
+ export interface ParallelSeries {
12
+ label: string
13
+ color?: string
14
+ values: number[]
15
+ }
16
+
17
+ export interface ParallelCoordinatesProps {
18
+ axes: ParallelAxis[]
19
+ series: ParallelSeries[]
20
+ height?: number
21
+ ariaLabel?: string
22
+ }
23
+
24
+ export function ParallelCoordinates({
25
+ axes,
26
+ series,
27
+ height = 200,
28
+ ariaLabel = 'Parallel coordinates chart',
29
+ }: ParallelCoordinatesProps) {
30
+ const VW = 380
31
+ const VH = height
32
+ const PAD_L = 28
33
+ const PAD_R = 28
34
+ const PAD_T = 32
35
+ const PAD_B = 20
36
+
37
+ const TRACK_W = VW - PAD_L - PAD_R
38
+ const TRACK_H = VH - PAD_T - PAD_B
39
+ const nAxes = axes.length
40
+
41
+ function axisX(i: number): number {
42
+ return PAD_L + (nAxes > 1 ? i * (TRACK_W / (nAxes - 1)) : TRACK_W / 2)
43
+ }
44
+
45
+ function valueY(axis: ParallelAxis, v: number): number {
46
+ const t = Math.max(0, Math.min(1, (v - axis.min) / (axis.max - axis.min)))
47
+ return axis.invert ? PAD_T + t * TRACK_H : PAD_T + (1 - t) * TRACK_H
48
+ }
49
+
50
+ function seriesPath(s: ParallelSeries): string {
51
+ return axes
52
+ .map((axis, i) => {
53
+ const x = axisX(i).toFixed(1)
54
+ const y = valueY(axis, s.values[i] ?? axis.min).toFixed(1)
55
+ return i === 0 ? `M${x},${y}` : `L${x},${y}`
56
+ })
57
+ .join(' ')
58
+ }
59
+
60
+ const TICKS = [0, 0.5, 1]
61
+
62
+ return (
63
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
64
+ <svg
65
+ viewBox={`0 0 ${VW} ${VH}`}
66
+ style={{ width: '100%', height: 'auto', display: 'block' }}
67
+ aria-hidden="true"
68
+ focusable="false"
69
+ >
70
+ {axes.map((axis, i) => {
71
+ const x = axisX(i)
72
+ return (
73
+ <g key={i}>
74
+ <line
75
+ x1={x}
76
+ y1={PAD_T}
77
+ x2={x}
78
+ y2={VH - PAD_B}
79
+ stroke="rgba(255,255,255,0.2)"
80
+ strokeWidth={1}
81
+ />
82
+ <text
83
+ x={x}
84
+ y={PAD_T - 8}
85
+ fill="#A4A7B5"
86
+ fontSize={8}
87
+ textAnchor="middle"
88
+ fontFamily="inherit"
89
+ >
90
+ {axis.label}
91
+ </text>
92
+ {axis.unit && (
93
+ <text
94
+ x={x}
95
+ y={PAD_T - 1}
96
+ fill="#6E7079"
97
+ fontSize={7}
98
+ textAnchor="middle"
99
+ fontFamily="inherit"
100
+ >
101
+ {axis.unit}
102
+ </text>
103
+ )}
104
+ {TICKS.map((t) => {
105
+ const v = axis.invert
106
+ ? axis.min + t * (axis.max - axis.min)
107
+ : axis.max - t * (axis.max - axis.min)
108
+ const y = PAD_T + t * TRACK_H
109
+ return (
110
+ <g key={t}>
111
+ <line
112
+ x1={x - 3}
113
+ y1={y}
114
+ x2={x + 3}
115
+ y2={y}
116
+ stroke="rgba(255,255,255,0.25)"
117
+ strokeWidth={1}
118
+ />
119
+ <text
120
+ x={x - 5}
121
+ y={y + 3}
122
+ fill="#6E7079"
123
+ fontSize={7}
124
+ textAnchor="end"
125
+ fontFamily="inherit"
126
+ >
127
+ {v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}
128
+ </text>
129
+ </g>
130
+ )
131
+ })}
132
+ </g>
133
+ )
134
+ })}
135
+
136
+ {series.map((s, si) => (
137
+ <path
138
+ key={si}
139
+ d={seriesPath(s)}
140
+ fill="none"
141
+ stroke={s.color ?? COLORS[si % COLORS.length]}
142
+ strokeWidth={1.5}
143
+ opacity={0.75}
144
+ />
145
+ ))}
146
+ </svg>
147
+ </div>
148
+ )
149
+ }