@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,133 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface ChordNode {
4
+ label: string
5
+ color?: string
6
+ }
7
+
8
+ export interface ChordDiagramProps {
9
+ nodes: ChordNode[]
10
+ matrix: number[][]
11
+ ariaLabel?: string
12
+ }
13
+
14
+ const PALETTE = ['#006FFF', '#00C875', '#A78BFA', '#F5A623', '#50B8E7', '#F04949']
15
+ const CX = 160,
16
+ CY = 160,
17
+ R_IN = 108,
18
+ R_ARC = 125,
19
+ R_LBL = 138,
20
+ PAD = 0.04
21
+
22
+ function pt(r: number, a: number) {
23
+ return `${(CX + r * Math.cos(a)).toFixed(1)},${(CY + r * Math.sin(a)).toFixed(1)}`
24
+ }
25
+
26
+ function arcSlice(r1: number, r2: number, a1: number, a2: number) {
27
+ const lg = a2 - a1 > Math.PI ? 1 : 0
28
+ return `M${pt(r2, a1)} A${r2},${r2} 0 ${lg},1 ${pt(r2, a2)} L${pt(r1, a2)} A${r1},${r1} 0 ${lg},0 ${pt(r1, a1)} Z`
29
+ }
30
+
31
+ function ribbon(a1: number, a2: number, b1: number, b2: number) {
32
+ const la = +(a2 - a1 > Math.PI)
33
+ const lb = +(b2 - b1 > Math.PI)
34
+ return `M${pt(R_IN, a1)} A${R_IN},${R_IN} 0 ${la},1 ${pt(R_IN, a2)} Q${CX},${CY} ${pt(R_IN, b2)} A${R_IN},${R_IN} 0 ${lb},0 ${pt(R_IN, b1)} Q${CX},${CY} ${pt(R_IN, a1)}Z`
35
+ }
36
+
37
+ export function ChordDiagram({ nodes, matrix, ariaLabel = 'Chord diagram' }: ChordDiagramProps) {
38
+ const { arcs, chords, labels } = useMemo(() => {
39
+ const n = nodes.length
40
+ const totals = nodes.map((_, i) => matrix[i].reduce((s, v) => s + v, 0))
41
+ const grand = totals.reduce((s, v) => s + v, 0)
42
+ if (!grand)
43
+ return {
44
+ arcs: [] as { d: string; fill: string }[],
45
+ chords: [] as { d: string; fill: string }[],
46
+ labels: [] as { x: number; y: number; anchor: 'start' | 'end' | 'middle'; text: string }[],
47
+ }
48
+
49
+ const scale = (2 * Math.PI - PAD * n) / grand
50
+
51
+ const nodeA: { start: number; end: number; offsets: number[] }[] = []
52
+ let angle = -Math.PI / 2
53
+ for (let i = 0; i < n; i++) {
54
+ const span = totals[i] * scale
55
+ const offsets: number[] = []
56
+ let off = angle
57
+ for (let j = 0; j < n; j++) {
58
+ offsets.push(off)
59
+ off += matrix[i][j] * scale
60
+ }
61
+ nodeA.push({ start: angle, end: angle + span, offsets })
62
+ angle += span + PAD
63
+ }
64
+
65
+ const arcs = nodeA.map((a, i) => ({
66
+ d: arcSlice(R_IN + 2, R_ARC, a.start, a.end),
67
+ fill: nodes[i].color ?? PALETTE[i % PALETTE.length],
68
+ }))
69
+
70
+ const chords: { d: string; fill: string }[] = []
71
+ for (let i = 0; i < n; i++) {
72
+ for (let j = i + 1; j < n; j++) {
73
+ const vij = matrix[i][j],
74
+ vji = matrix[j][i]
75
+ if (!vij && !vji) continue
76
+ const a1 = nodeA[i].offsets[j],
77
+ a2 = a1 + vij * scale
78
+ const b1 = nodeA[j].offsets[i],
79
+ b2 = b1 + vji * scale
80
+ chords.push({
81
+ d: ribbon(a1, a2, b1, b2),
82
+ fill: (nodes[i].color ?? PALETTE[i % PALETTE.length]) + '55',
83
+ })
84
+ }
85
+ }
86
+
87
+ const labels = nodeA.map((a, i) => {
88
+ const mid = (a.start + a.end) / 2
89
+ const anchor: 'start' | 'end' | 'middle' =
90
+ Math.cos(mid) > 0.2 ? 'start' : Math.cos(mid) < -0.2 ? 'end' : 'middle'
91
+ return {
92
+ x: CX + R_LBL * Math.cos(mid),
93
+ y: CY + R_LBL * Math.sin(mid) + 4,
94
+ anchor,
95
+ text: nodes[i].label,
96
+ }
97
+ })
98
+
99
+ return { arcs, chords, labels }
100
+ }, [nodes, matrix])
101
+
102
+ return (
103
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
104
+ <svg
105
+ viewBox="0 0 320 320"
106
+ style={{ width: '100%', height: 'auto', display: 'block' }}
107
+ aria-hidden="true"
108
+ focusable="false"
109
+ >
110
+ {chords.map((c, i) => (
111
+ <path key={i} d={c.d} fill={c.fill} />
112
+ ))}
113
+ {arcs.map((a, i) => (
114
+ <path key={i} d={a.d} fill={a.fill} />
115
+ ))}
116
+ {labels.map((l, i) => (
117
+ <text
118
+ key={i}
119
+ x={l.x.toFixed(1)}
120
+ y={l.y.toFixed(1)}
121
+ textAnchor={l.anchor}
122
+ fill="#fff"
123
+ fontSize={10}
124
+ fontWeight={600}
125
+ fontFamily="inherit"
126
+ >
127
+ {l.text}
128
+ </text>
129
+ ))}
130
+ </svg>
131
+ </div>
132
+ )
133
+ }
@@ -0,0 +1,189 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface CirclePackItem {
4
+ id: string
5
+ label: string
6
+ value: number
7
+ color?: string
8
+ }
9
+
10
+ export interface CirclePackingProps {
11
+ items: CirclePackItem[]
12
+ height?: number
13
+ ariaLabel?: string
14
+ }
15
+
16
+ const PALETTE = [
17
+ '#006FFF',
18
+ '#00C875',
19
+ '#F5A623',
20
+ '#A78BFA',
21
+ '#FF7B7B',
22
+ '#00C8C8',
23
+ '#FB923C',
24
+ '#4797FF',
25
+ ]
26
+ const VW = 360
27
+ const MAX_R = 72
28
+ const MIN_R = 10
29
+
30
+ function pack(radii: number[]): { cx: number; cy: number }[] {
31
+ const pos: { cx: number; cy: number; r: number }[] = []
32
+
33
+ for (let i = 0; i < radii.length; i++) {
34
+ const r = radii[i]
35
+ if (i === 0) {
36
+ pos.push({ cx: 0, cy: 0, r })
37
+ continue
38
+ }
39
+ if (i === 1) {
40
+ pos.push({ cx: pos[0].r + r, cy: 0, r })
41
+ continue
42
+ }
43
+
44
+ // Candidates: tangent to each pair of placed circles
45
+ const candidates: { cx: number; cy: number }[] = []
46
+ for (let a = 0; a < pos.length; a++) {
47
+ for (let b = a + 1; b < pos.length; b++) {
48
+ const pa = pos[a],
49
+ pb = pos[b]
50
+ const da = pa.r + r,
51
+ db = pb.r + r
52
+ const dx = pb.cx - pa.cx,
53
+ dy = pb.cy - pa.cy
54
+ const dist = Math.hypot(dx, dy)
55
+ if (dist > da + db || dist < Math.abs(da - db)) continue
56
+ const cosA = (da * da + dist * dist - db * db) / (2 * da * dist)
57
+ if (cosA < -1 || cosA > 1) continue
58
+ const acos = Math.acos(Math.max(-1, Math.min(1, cosA)))
59
+ const base = Math.atan2(dy, dx)
60
+ for (const sign of [1, -1]) {
61
+ candidates.push({
62
+ cx: pa.cx + da * Math.cos(base + sign * acos),
63
+ cy: pa.cy + da * Math.sin(base + sign * acos),
64
+ })
65
+ }
66
+ }
67
+ // Also tangent to single circle toward origin
68
+ const pa = pos[a]
69
+ const ang = Math.atan2(-pa.cy, -pa.cx)
70
+ candidates.push({
71
+ cx: pa.cx + (pa.r + r) * Math.cos(ang),
72
+ cy: pa.cy + (pa.r + r) * Math.sin(ang),
73
+ })
74
+ }
75
+
76
+ let best: { cx: number; cy: number } | null = null
77
+ let bestDist = Infinity
78
+ for (const c of candidates) {
79
+ const overlap = pos.some((p) => Math.hypot(c.cx - p.cx, c.cy - p.cy) < p.r + r - 0.4)
80
+ if (!overlap) {
81
+ const d = Math.hypot(c.cx, c.cy)
82
+ if (d < bestDist) {
83
+ bestDist = d
84
+ best = c
85
+ }
86
+ }
87
+ }
88
+ const last = pos[pos.length - 1]
89
+ pos.push({ cx: best?.cx ?? last.cx + last.r + r + 2, cy: best?.cy ?? 0, r })
90
+ }
91
+
92
+ return pos.map(({ cx, cy }) => ({ cx, cy }))
93
+ }
94
+
95
+ export function CirclePacking({
96
+ items,
97
+ height = 200,
98
+ ariaLabel = 'Circle packing chart',
99
+ }: CirclePackingProps) {
100
+ const layout = useMemo(() => {
101
+ if (!items.length) return null
102
+ const sorted = [...items].sort((a, b) => b.value - a.value)
103
+ const maxV = sorted[0].value
104
+ const radii = sorted.map((it) =>
105
+ Math.max(MIN_R, Math.round(MAX_R * Math.sqrt(it.value / maxV))),
106
+ )
107
+ const positions = pack(radii)
108
+
109
+ // Compute bounding box and center in viewBox
110
+ let minX = Infinity,
111
+ minY = Infinity,
112
+ maxX = -Infinity,
113
+ maxY = -Infinity
114
+ positions.forEach((p, i) => {
115
+ minX = Math.min(minX, p.cx - radii[i])
116
+ minY = Math.min(minY, p.cy - radii[i])
117
+ maxX = Math.max(maxX, p.cx + radii[i])
118
+ maxY = Math.max(maxY, p.cy + radii[i])
119
+ })
120
+ const w = maxX - minX,
121
+ h = maxY - minY
122
+ const pad = 8
123
+ const scale = Math.min((VW - pad * 2) / w, (height - pad * 2) / h)
124
+ const offX = (VW - w * scale) / 2 - minX * scale
125
+ const offY = (height - h * scale) / 2 - minY * scale
126
+
127
+ return sorted.map((item, i) => {
128
+ const r = radii[i] * scale
129
+ const cx = positions[i].cx * scale + offX
130
+ const cy = positions[i].cy * scale + offY
131
+ const color = item.color ?? PALETTE[i % PALETTE.length]
132
+ return { id: item.id, label: item.label, value: item.value, cx, cy, r, color }
133
+ })
134
+ }, [items, height])
135
+
136
+ if (!layout) return null
137
+
138
+ return (
139
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
140
+ <svg
141
+ viewBox={`0 0 ${VW} ${height}`}
142
+ style={{ width: '100%', height: 'auto', display: 'block' }}
143
+ aria-hidden="true"
144
+ focusable="false"
145
+ >
146
+ {layout.map((c) => (
147
+ <g key={c.id}>
148
+ <circle
149
+ cx={c.cx}
150
+ cy={c.cy}
151
+ r={c.r}
152
+ fill={c.color}
153
+ fillOpacity={0.25}
154
+ stroke={c.color}
155
+ strokeWidth={1.5}
156
+ />
157
+ {c.r >= 18 && (
158
+ <text
159
+ x={c.cx}
160
+ y={c.cy - (c.r >= 26 ? 5 : 0)}
161
+ textAnchor="middle"
162
+ dominantBaseline="middle"
163
+ fontSize={Math.min(11, c.r * 0.38)}
164
+ fill="#E2E4ED"
165
+ fontFamily="inherit"
166
+ fontWeight={500}
167
+ >
168
+ {c.label}
169
+ </text>
170
+ )}
171
+ {c.r >= 26 && (
172
+ <text
173
+ x={c.cx}
174
+ y={c.cy + 10}
175
+ textAnchor="middle"
176
+ dominantBaseline="middle"
177
+ fontSize={Math.min(9, c.r * 0.3)}
178
+ fill={c.color}
179
+ fontFamily="inherit"
180
+ >
181
+ {c.value}
182
+ </text>
183
+ )}
184
+ </g>
185
+ ))}
186
+ </svg>
187
+ </div>
188
+ )
189
+ }
@@ -0,0 +1,27 @@
1
+ import { CopyButton } from './CopyButton.js'
2
+
3
+ export interface CodeBlockProps {
4
+ code: string
5
+ label?: string
6
+ language?: string
7
+ className?: string
8
+ }
9
+
10
+ export function CodeBlock({
11
+ code,
12
+ label = 'Code block',
13
+ language,
14
+ className = '',
15
+ }: CodeBlockProps) {
16
+ return (
17
+ <div role="region" aria-label={label} className={`code-block ${className}`.trim()}>
18
+ <div className="code-block__header">
19
+ {language && <span className="code-block__lang">{language}</span>}
20
+ <CopyButton text={code} label="Copy code" />
21
+ </div>
22
+ <pre className="code-block__pre">
23
+ <code className="code-block__code">{code}</code>
24
+ </pre>
25
+ </div>
26
+ )
27
+ }
@@ -0,0 +1,120 @@
1
+ import { useId, useState, useRef, useCallback } from 'react'
2
+
3
+ export interface ColorSwatchDef {
4
+ value: string
5
+ label: string
6
+ color: string
7
+ }
8
+
9
+ export const COLOR_SWATCHES: ColorSwatchDef[] = [
10
+ { value: 'blue', label: 'Blue', color: '#006FFF' },
11
+ { value: 'green', label: 'Green', color: '#00B070' },
12
+ { value: 'amber', label: 'Amber', color: '#F5A623' },
13
+ { value: 'red', label: 'Red', color: '#F03A3A' },
14
+ { value: 'purple', label: 'Purple', color: '#9B59B6' },
15
+ { value: 'teal', label: 'Teal', color: '#1ABC9C' },
16
+ { value: 'pink', label: 'Pink', color: '#E91E8C' },
17
+ { value: 'orange', label: 'Orange', color: '#FF6B35' },
18
+ { value: 'slate', label: 'Slate', color: '#6E7079' },
19
+ { value: 'light', label: 'Light', color: '#E8E8EC' },
20
+ ]
21
+
22
+ export interface ColorPickerProps {
23
+ label?: string
24
+ srOnlyLabel?: boolean
25
+ value?: string
26
+ defaultValue?: string
27
+ swatches?: ColorSwatchDef[]
28
+ onChange?: (value: string) => void
29
+ disabled?: boolean
30
+ }
31
+
32
+ export function ColorPicker({
33
+ label = 'Colour',
34
+ srOnlyLabel = false,
35
+ value,
36
+ defaultValue,
37
+ swatches = COLOR_SWATCHES,
38
+ onChange,
39
+ disabled = false,
40
+ }: ColorPickerProps) {
41
+ const groupId = useId()
42
+ const [internal, setInternal] = useState(defaultValue ?? swatches[0]?.value ?? '')
43
+ const controlled = value !== undefined
44
+ const current = controlled ? value : internal
45
+ const gridRef = useRef<HTMLDivElement>(null)
46
+
47
+ const set = useCallback(
48
+ (v: string) => {
49
+ if (!controlled) setInternal(v)
50
+ onChange?.(v)
51
+ },
52
+ [controlled, onChange],
53
+ )
54
+
55
+ const handleKeyDown = useCallback(
56
+ (e: React.KeyboardEvent) => {
57
+ const cells = Array.from(
58
+ gridRef.current?.querySelectorAll<HTMLInputElement>('input[type="radio"]') ?? [],
59
+ )
60
+ if (!cells.length) return
61
+ const idx = cells.findIndex((c) => c.value === current)
62
+ let next = idx
63
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (idx + 1) % cells.length
64
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
65
+ next = (idx - 1 + cells.length) % cells.length
66
+ else if (e.key === 'Home') next = 0
67
+ else if (e.key === 'End') next = cells.length - 1
68
+ else return
69
+ e.preventDefault()
70
+ cells[next].focus()
71
+ set(cells[next].value)
72
+ },
73
+ [current, set],
74
+ )
75
+
76
+ const hasMatch = swatches.some((s) => s.value === current)
77
+
78
+ return (
79
+ <div className={`color-picker${disabled ? ' color-picker--disabled' : ''}`}>
80
+ <div
81
+ id={`${groupId}-label`}
82
+ className={`color-picker__label${srOnlyLabel ? ' sr-only' : ''}`}
83
+ >
84
+ {label}
85
+ </div>
86
+ <div
87
+ ref={gridRef}
88
+ role="radiogroup"
89
+ aria-labelledby={`${groupId}-label`}
90
+ className="color-picker__grid"
91
+ onKeyDown={handleKeyDown}
92
+ >
93
+ {swatches.map((sw, i) => {
94
+ const checked = sw.value === current
95
+ const tabIdx = checked || (i === 0 && !hasMatch) ? 0 : -1
96
+ return (
97
+ <label key={sw.value} className="color-picker__swatch" title={sw.label}>
98
+ <input
99
+ type="radio"
100
+ name={groupId}
101
+ value={sw.value}
102
+ checked={checked}
103
+ onChange={() => set(sw.value)}
104
+ disabled={disabled}
105
+ className="sr-only"
106
+ tabIndex={tabIdx}
107
+ aria-label={sw.label}
108
+ />
109
+ <span
110
+ className={`color-picker__circle${checked ? ' color-picker__circle--selected' : ''}`}
111
+ style={{ background: sw.color }}
112
+ aria-hidden="true"
113
+ />
114
+ </label>
115
+ )
116
+ })}
117
+ </div>
118
+ </div>
119
+ )
120
+ }
@@ -0,0 +1,106 @@
1
+ import { useState, useRef, useCallback, useEffect, useId } from 'react'
2
+
3
+ const ColumnsIcon = () => (
4
+ <svg viewBox="0 0 14 14" width="14" height="14" fill="none" aria-hidden="true" focusable={false}>
5
+ <rect x="1" y="2" width="3" height="10" rx="1" stroke="currentColor" strokeWidth="1.3" />
6
+ <rect x="5.5" y="2" width="3" height="10" rx="1" stroke="currentColor" strokeWidth="1.3" />
7
+ <rect x="10" y="2" width="3" height="10" rx="1" stroke="currentColor" strokeWidth="1.3" />
8
+ </svg>
9
+ )
10
+
11
+ export interface ColumnDef {
12
+ key: string
13
+ label: string
14
+ required?: boolean
15
+ }
16
+
17
+ export interface ColumnToggleProps {
18
+ columns: ColumnDef[]
19
+ visible: Set<string>
20
+ onChange: (visible: Set<string>) => void
21
+ }
22
+
23
+ export function ColumnToggle({ columns, visible, onChange }: ColumnToggleProps) {
24
+ const [open, setOpen] = useState(false)
25
+ const btnRef = useRef<HTMLButtonElement>(null)
26
+ const panelRef = useRef<HTMLDivElement>(null)
27
+ const panelId = useId()
28
+
29
+ const close = useCallback(() => {
30
+ setOpen(false)
31
+ btnRef.current?.focus()
32
+ }, [])
33
+
34
+ useEffect(() => {
35
+ if (!open) return
36
+ function onKey(e: KeyboardEvent) {
37
+ if (e.key === 'Escape') close()
38
+ }
39
+ function onDown(e: MouseEvent) {
40
+ if (
41
+ !panelRef.current?.contains(e.target as Node) &&
42
+ !btnRef.current?.contains(e.target as Node)
43
+ )
44
+ close()
45
+ }
46
+ document.addEventListener('keydown', onKey)
47
+ document.addEventListener('mousedown', onDown)
48
+ return () => {
49
+ document.removeEventListener('keydown', onKey)
50
+ document.removeEventListener('mousedown', onDown)
51
+ }
52
+ }, [open, close])
53
+
54
+ function toggle(key: string) {
55
+ const next = new Set(visible)
56
+ if (next.has(key)) next.delete(key)
57
+ else next.add(key)
58
+ onChange(next)
59
+ }
60
+
61
+ return (
62
+ <div className="col-toggle">
63
+ <button
64
+ ref={btnRef}
65
+ type="button"
66
+ className={`col-toggle__btn${open ? ' col-toggle__btn--active' : ''}`}
67
+ aria-haspopup="dialog"
68
+ aria-expanded={open}
69
+ aria-controls={panelId}
70
+ onClick={() => setOpen((v) => !v)}
71
+ >
72
+ <ColumnsIcon />
73
+ <span>Columns</span>
74
+ </button>
75
+ {open && (
76
+ <div
77
+ ref={panelRef}
78
+ id={panelId}
79
+ className="col-toggle__panel"
80
+ role="dialog"
81
+ aria-label="Toggle columns"
82
+ >
83
+ <div className="col-toggle__header">Columns</div>
84
+ <ul className="col-toggle__list">
85
+ {columns.map((col) => (
86
+ <li key={col.key} className="col-toggle__item">
87
+ <label className="col-toggle__label">
88
+ <input
89
+ type="checkbox"
90
+ className="col-toggle__check"
91
+ checked={visible.has(col.key)}
92
+ disabled={col.required}
93
+ onChange={() => {
94
+ if (!col.required) toggle(col.key)
95
+ }}
96
+ />
97
+ <span>{col.label}</span>
98
+ </label>
99
+ </li>
100
+ ))}
101
+ </ul>
102
+ </div>
103
+ )}
104
+ </div>
105
+ )
106
+ }