@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,130 @@
1
+ import { useState } from 'react'
2
+
3
+ export interface SortableItem {
4
+ id: string
5
+ label: string
6
+ meta?: string
7
+ }
8
+
9
+ export interface SortableListProps {
10
+ items: SortableItem[]
11
+ onChange: (items: SortableItem[]) => void
12
+ ariaLabel?: string
13
+ }
14
+
15
+ function GrabIcon() {
16
+ return (
17
+ <svg
18
+ width="14"
19
+ height="14"
20
+ viewBox="0 0 14 14"
21
+ fill="currentColor"
22
+ aria-hidden="true"
23
+ focusable="false"
24
+ >
25
+ <rect x="3" y="2" width="2" height="2" rx="1" />
26
+ <rect x="9" y="2" width="2" height="2" rx="1" />
27
+ <rect x="3" y="6" width="2" height="2" rx="1" />
28
+ <rect x="9" y="6" width="2" height="2" rx="1" />
29
+ <rect x="3" y="10" width="2" height="2" rx="1" />
30
+ <rect x="9" y="10" width="2" height="2" rx="1" />
31
+ </svg>
32
+ )
33
+ }
34
+
35
+ export function SortableList({ items, onChange, ariaLabel }: SortableListProps) {
36
+ const [grabbed, setGrabbed] = useState<string | null>(null)
37
+ const [dragSrc, setDragSrc] = useState<string | null>(null)
38
+ const [dragOver, setDragOver] = useState<string | null>(null)
39
+
40
+ function reorder(fromId: string, toId: string) {
41
+ if (fromId === toId) return
42
+ const from = items.findIndex((i) => i.id === fromId)
43
+ const to = items.findIndex((i) => i.id === toId)
44
+ const arr = [...items]
45
+ const [item] = arr.splice(from, 1)
46
+ arr.splice(to, 0, item)
47
+ onChange(arr)
48
+ }
49
+
50
+ function move(id: string, dir: -1 | 1) {
51
+ const idx = items.findIndex((i) => i.id === id)
52
+ const next = idx + dir
53
+ if (next < 0 || next >= items.length) return
54
+ const arr = [...items]
55
+ ;[arr[idx], arr[next]] = [arr[next], arr[idx]]
56
+ onChange(arr)
57
+ }
58
+
59
+ function handleKeyDown(e: React.KeyboardEvent<HTMLButtonElement>, id: string) {
60
+ if (e.key === ' ' || e.key === 'Enter') {
61
+ e.preventDefault()
62
+ setGrabbed((g) => (g === id ? null : id))
63
+ } else if (grabbed === id) {
64
+ if (e.key === 'ArrowUp') {
65
+ e.preventDefault()
66
+ move(id, -1)
67
+ } else if (e.key === 'ArrowDown') {
68
+ e.preventDefault()
69
+ move(id, 1)
70
+ } else if (e.key === 'Escape') {
71
+ setGrabbed(null)
72
+ }
73
+ }
74
+ }
75
+
76
+ return (
77
+ <ol className="sortable-list" aria-label={ariaLabel}>
78
+ {items.map((item, idx) => (
79
+ <li
80
+ key={item.id}
81
+ className={[
82
+ 'sortable-list__item',
83
+ grabbed === item.id ? 'is-grabbed' : '',
84
+ dragOver === item.id ? 'is-drag-over' : '',
85
+ ]
86
+ .filter(Boolean)
87
+ .join(' ')}
88
+ draggable
89
+ onDragStart={(e) => {
90
+ e.dataTransfer.effectAllowed = 'move'
91
+ setDragSrc(item.id)
92
+ }}
93
+ onDragEnd={() => {
94
+ setDragSrc(null)
95
+ setDragOver(null)
96
+ }}
97
+ onDragOver={(e) => {
98
+ e.preventDefault()
99
+ e.dataTransfer.dropEffect = 'move'
100
+ if (dragOver !== item.id) setDragOver(item.id)
101
+ }}
102
+ onDragLeave={() => setDragOver(null)}
103
+ onDrop={(e) => {
104
+ e.preventDefault()
105
+ if (dragSrc) reorder(dragSrc, item.id)
106
+ setDragSrc(null)
107
+ setDragOver(null)
108
+ }}
109
+ >
110
+ <button
111
+ type="button"
112
+ className="sortable-list__handle"
113
+ aria-label={`${grabbed === item.id ? 'Release' : 'Grab'} ${item.label}. Use arrow keys to reorder.`}
114
+ aria-pressed={grabbed === item.id}
115
+ onKeyDown={(e) => handleKeyDown(e, item.id)}
116
+ >
117
+ <GrabIcon />
118
+ </button>
119
+ <span className="sortable-list__content">
120
+ <span className="sortable-list__label">{item.label}</span>
121
+ {item.meta && <span className="sortable-list__meta">{item.meta}</span>}
122
+ </span>
123
+ <span className="sortable-list__index" aria-hidden="true">
124
+ {idx + 1}
125
+ </span>
126
+ </li>
127
+ ))}
128
+ </ol>
129
+ )
130
+ }
@@ -0,0 +1,46 @@
1
+ import { useState, type CSSProperties, type ReactNode } from 'react'
2
+
3
+ export type SortDir = 'asc' | 'desc'
4
+
5
+ export function useSortable() {
6
+ const [sortKey, setSortKey] = useState<string | null>(null)
7
+ const [dir, setDir] = useState<SortDir>('asc')
8
+
9
+ function onSort(key: string) {
10
+ if (sortKey === key) setDir((d) => (d === 'asc' ? 'desc' : 'asc'))
11
+ else {
12
+ setSortKey(key)
13
+ setDir('asc')
14
+ }
15
+ }
16
+
17
+ return { sortKey, dir, onSort }
18
+ }
19
+
20
+ export interface SortHeaderProps {
21
+ sortKey: string
22
+ activeKey: string | null
23
+ dir: SortDir
24
+ onSort: (key: string) => void
25
+ children: ReactNode
26
+ style?: CSSProperties
27
+ }
28
+
29
+ export function SortHeader({ sortKey, activeKey, dir, onSort, children, style }: SortHeaderProps) {
30
+ const active = activeKey === sortKey
31
+ return (
32
+ <th
33
+ scope="col"
34
+ aria-sort={active ? (dir === 'asc' ? 'ascending' : 'descending') : 'none'}
35
+ style={style}
36
+ >
37
+ <button type="button" className="sort-header-btn" onClick={() => onSort(sortKey)}>
38
+ {children}
39
+ <svg className="sort-icon" viewBox="0 0 8 11" aria-hidden="true" fill="currentColor">
40
+ <path d="M4 0L7 4H1Z" opacity={active && dir === 'asc' ? 1 : 0.3} />
41
+ <path d="M4 11L1 7H7Z" opacity={active && dir === 'desc' ? 1 : 0.3} />
42
+ </svg>
43
+ </button>
44
+ </th>
45
+ )
46
+ }
@@ -0,0 +1,50 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface SparklineProps {
4
+ bars?: number
5
+ active?: boolean
6
+ seed?: number
7
+ ariaLabel?: string
8
+ }
9
+
10
+ function rng(seed: number) {
11
+ let s = seed
12
+ return () => {
13
+ s = (s * 9301 + 49297) % 233280
14
+ return s / 233280
15
+ }
16
+ }
17
+
18
+ export function Sparkline({
19
+ bars = 36,
20
+ active = false,
21
+ seed = 1,
22
+ ariaLabel = 'Activity sparkline',
23
+ }: SparklineProps) {
24
+ const heights = useMemo(() => {
25
+ const out: number[] = []
26
+ let prev = 12
27
+ const r = rng(seed)
28
+ for (let i = 0; i < bars; i++) {
29
+ const v = Math.max(4, Math.min(40, prev + (r() * 10 - 5)))
30
+ prev = v
31
+ out.push(v)
32
+ }
33
+ return out
34
+ }, [bars, seed])
35
+ return (
36
+ <div className="spark" role="img" aria-label={ariaLabel}>
37
+ {heights.map((h, i) => (
38
+ <div
39
+ key={i}
40
+ aria-hidden="true"
41
+ className="b"
42
+ style={{
43
+ height: `${h}px`,
44
+ opacity: active ? 0.4 + i / (bars * 1.4) : 0.5 + i / (bars * 1.6),
45
+ }}
46
+ />
47
+ ))}
48
+ </div>
49
+ )
50
+ }
@@ -0,0 +1,117 @@
1
+ export interface SparklineMatrixRow {
2
+ label: string
3
+ values: number[]
4
+ unit?: string
5
+ color?: string
6
+ }
7
+
8
+ export interface SparklineMatrixProps {
9
+ rows: SparklineMatrixRow[]
10
+ height?: number
11
+ ariaLabel?: string
12
+ }
13
+
14
+ const VW = 380
15
+ const PAD_L = 76
16
+ const PAD_R = 44
17
+ const SPARK_PAD_X = 8
18
+ const SPARK_PAD_Y = 5
19
+ const PALETTE = ['#006FFF', '#00C875', '#7FB6FF', '#F5A623', '#FF7B7B', '#A78BFA']
20
+
21
+ export function SparklineMatrix({
22
+ rows,
23
+ height = 160,
24
+ ariaLabel = 'Sparkline matrix',
25
+ }: SparklineMatrixProps) {
26
+ if (!rows.length) return null
27
+
28
+ const rowH = height / rows.length
29
+ const sparkX0 = PAD_L + SPARK_PAD_X
30
+ const sparkW = VW - PAD_L - PAD_R - SPARK_PAD_X * 2
31
+ const sparkH = rowH - SPARK_PAD_Y * 2
32
+
33
+ const computed = rows.map((row, i) => {
34
+ const y0 = i * rowH + SPARK_PAD_Y
35
+ const color = row.color ?? PALETTE[i % PALETTE.length]
36
+ const min = Math.min(...row.values)
37
+ const max = Math.max(...row.values)
38
+ const range = max - min || 1
39
+ const lastVal = row.values[row.values.length - 1]
40
+
41
+ const d =
42
+ row.values.length < 2
43
+ ? ''
44
+ : row.values
45
+ .map((v, j) => {
46
+ const x = sparkX0 + (j / (row.values.length - 1)) * sparkW
47
+ const y = y0 + sparkH - ((v - min) / range) * sparkH
48
+ return `${j === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`
49
+ })
50
+ .join(' ')
51
+
52
+ const dotY = y0 + sparkH - ((lastVal - min) / range) * sparkH
53
+ return { color, lastVal, d, dotX: sparkX0 + sparkW, dotY }
54
+ })
55
+
56
+ return (
57
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
58
+ <svg
59
+ viewBox={`0 0 ${VW} ${height}`}
60
+ style={{ width: '100%', height: 'auto', display: 'block' }}
61
+ aria-hidden="true"
62
+ focusable="false"
63
+ >
64
+ {rows.map((row, i) => {
65
+ const midY = i * rowH + rowH / 2
66
+ const { color, lastVal, d, dotX, dotY } = computed[i]
67
+ return (
68
+ <g key={i}>
69
+ {i > 0 && (
70
+ <line
71
+ x1={0}
72
+ y1={i * rowH}
73
+ x2={VW}
74
+ y2={i * rowH}
75
+ stroke="rgba(255,255,255,0.06)"
76
+ strokeWidth={1}
77
+ />
78
+ )}
79
+ <text
80
+ x={PAD_L - 6}
81
+ y={midY + 3.5}
82
+ textAnchor="end"
83
+ fill="#6E7079"
84
+ fontSize={9}
85
+ fontFamily="inherit"
86
+ >
87
+ {row.label}
88
+ </text>
89
+ {d && (
90
+ <path
91
+ d={d}
92
+ fill="none"
93
+ stroke={color}
94
+ strokeWidth={1.5}
95
+ strokeLinejoin="round"
96
+ strokeLinecap="round"
97
+ />
98
+ )}
99
+ <circle cx={dotX} cy={dotY} r={2.5} fill={color} />
100
+ <text
101
+ x={VW - PAD_R + 6}
102
+ y={midY + 3.5}
103
+ textAnchor="start"
104
+ fill={color}
105
+ fontSize={9}
106
+ fontFamily="inherit"
107
+ >
108
+ {Math.round(lastVal)}
109
+ {row.unit ?? ''}
110
+ </text>
111
+ </g>
112
+ )
113
+ })}
114
+ </svg>
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,8 @@
1
+ export interface SpinnerProps {
2
+ size?: 'sm' | 'md' | 'lg'
3
+ label?: string
4
+ }
5
+
6
+ export function Spinner({ size = 'md', label = 'Loading' }: SpinnerProps) {
7
+ return <span role="status" aria-label={label} className={`spinner ${size}`} />
8
+ }
@@ -0,0 +1,151 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+ import type { ButtonVariant } from './Button.js'
4
+
5
+ export interface SplitButtonItem {
6
+ id: string
7
+ label: string
8
+ disabled?: boolean
9
+ }
10
+
11
+ export interface SplitButtonProps {
12
+ label: string
13
+ variant?: ButtonVariant
14
+ disabled?: boolean
15
+ items: SplitButtonItem[]
16
+ onPrimaryClick?: () => void
17
+ onAction?: (id: string) => void
18
+ }
19
+
20
+ export function SplitButton({
21
+ label,
22
+ variant = 'ghost',
23
+ disabled,
24
+ items,
25
+ onPrimaryClick,
26
+ onAction,
27
+ }: SplitButtonProps) {
28
+ const uid = useId()
29
+ const menuId = `${uid}-menu`
30
+ const [open, setOpen] = useState(false)
31
+ const [activeIdx, setActiveIdx] = useState(0)
32
+ const caretRef = useRef<HTMLButtonElement>(null)
33
+ const wrapperRef = useRef<HTMLDivElement>(null)
34
+
35
+ useEffect(() => {
36
+ if (!open) return
37
+ function handler(e: MouseEvent) {
38
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false)
39
+ }
40
+ document.addEventListener('mousedown', handler)
41
+ return () => document.removeEventListener('mousedown', handler)
42
+ }, [open])
43
+
44
+ function toggleMenu() {
45
+ setOpen((prev) => {
46
+ if (!prev) setActiveIdx(0)
47
+ return !prev
48
+ })
49
+ }
50
+
51
+ function activate(id: string) {
52
+ onAction?.(id)
53
+ setOpen(false)
54
+ caretRef.current?.focus()
55
+ }
56
+
57
+ function onKeyDown(e: KeyboardEvent) {
58
+ if (!open) {
59
+ if (['ArrowDown', 'Enter', ' '].includes(e.key)) {
60
+ e.preventDefault()
61
+ setActiveIdx(0)
62
+ setOpen(true)
63
+ }
64
+ return
65
+ }
66
+ if (e.key === 'Escape') {
67
+ e.preventDefault()
68
+ setOpen(false)
69
+ caretRef.current?.focus()
70
+ } else if (e.key === 'ArrowDown') {
71
+ e.preventDefault()
72
+ setActiveIdx((i) => Math.min(i + 1, items.length - 1))
73
+ } else if (e.key === 'ArrowUp') {
74
+ e.preventDefault()
75
+ setActiveIdx((i) => Math.max(i - 1, 0))
76
+ } else if (e.key === 'Home') {
77
+ e.preventDefault()
78
+ setActiveIdx(0)
79
+ } else if (e.key === 'End') {
80
+ e.preventDefault()
81
+ setActiveIdx(items.length - 1)
82
+ } else if (e.key === 'Enter' || e.key === ' ') {
83
+ e.preventDefault()
84
+ const item = items[activeIdx]
85
+ if (item && !item.disabled) activate(item.id)
86
+ } else if (e.key === 'Tab') {
87
+ setOpen(false)
88
+ }
89
+ }
90
+
91
+ return (
92
+ <div ref={wrapperRef} className="split-btn">
93
+ <button
94
+ type="button"
95
+ className={`btn btn-${variant} split-btn-primary`}
96
+ disabled={disabled}
97
+ onClick={onPrimaryClick}
98
+ >
99
+ {label}
100
+ </button>
101
+ <button
102
+ ref={caretRef}
103
+ type="button"
104
+ className={`btn btn-${variant} split-btn-caret`}
105
+ disabled={disabled}
106
+ aria-label={`${label} options`}
107
+ aria-haspopup="menu"
108
+ aria-expanded={open}
109
+ aria-controls={open ? menuId : undefined}
110
+ onClick={toggleMenu}
111
+ onKeyDown={onKeyDown}
112
+ >
113
+ <svg
114
+ width="10"
115
+ height="10"
116
+ viewBox="0 0 10 10"
117
+ aria-hidden="true"
118
+ focusable="false"
119
+ fill="none"
120
+ stroke="currentColor"
121
+ strokeWidth="1.5"
122
+ strokeLinecap="round"
123
+ strokeLinejoin="round"
124
+ >
125
+ <path d="M1 3 L5 7 L9 3" />
126
+ </svg>
127
+ </button>
128
+ {open && (
129
+ <ul id={menuId} role="menu" aria-label={`${label} options`} className="action-menu">
130
+ {items.map((item, idx) => (
131
+ <li
132
+ key={item.id}
133
+ role="menuitem"
134
+ tabIndex={-1}
135
+ aria-disabled={item.disabled}
136
+ data-active={idx === activeIdx ? 'true' : undefined}
137
+ className="action-menu-item"
138
+ onMouseEnter={() => setActiveIdx(idx)}
139
+ onMouseDown={(e) => {
140
+ e.preventDefault()
141
+ if (!item.disabled) activate(item.id)
142
+ }}
143
+ >
144
+ {item.label}
145
+ </li>
146
+ ))}
147
+ </ul>
148
+ )}
149
+ </div>
150
+ )
151
+ }
@@ -0,0 +1,47 @@
1
+ import { useState, useRef, useLayoutEffect, type ReactNode } from 'react'
2
+
3
+ export interface SpoilerProps {
4
+ children: ReactNode
5
+ maxHeight?: number
6
+ showLabel?: string
7
+ hideLabel?: string
8
+ className?: string
9
+ }
10
+
11
+ export function Spoiler({
12
+ children,
13
+ maxHeight = 80,
14
+ showLabel = 'Show more',
15
+ hideLabel = 'Show less',
16
+ className,
17
+ }: SpoilerProps) {
18
+ const [expanded, setExpanded] = useState(false)
19
+ const [clipped, setClipped] = useState(false)
20
+ const ref = useRef<HTMLDivElement>(null)
21
+
22
+ useLayoutEffect(() => {
23
+ const el = ref.current
24
+ if (el) setClipped(el.scrollHeight > maxHeight)
25
+ }, [maxHeight])
26
+
27
+ return (
28
+ <div className={`spoiler${className ? ' ' + className : ''}`}>
29
+ <div className="spoiler-outer" style={{ position: 'relative' }}>
30
+ <div ref={ref} style={!expanded && clipped ? { maxHeight, overflow: 'hidden' } : undefined}>
31
+ {children}
32
+ </div>
33
+ {!expanded && clipped && <div className="spoiler-fade" aria-hidden="true" />}
34
+ </div>
35
+ {clipped && (
36
+ <button
37
+ type="button"
38
+ className="spoiler-toggle"
39
+ aria-expanded={expanded}
40
+ onClick={() => setExpanded((v) => !v)}
41
+ >
42
+ {expanded ? hideLabel : showLabel}
43
+ </button>
44
+ )}
45
+ </div>
46
+ )
47
+ }
@@ -0,0 +1,145 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface StackedBarSeries {
4
+ label: string
5
+ color: string
6
+ values: number[]
7
+ }
8
+
9
+ export interface StackedBarChartProps {
10
+ series: StackedBarSeries[]
11
+ labels?: string[]
12
+ height?: number
13
+ normalized?: boolean
14
+ ariaLabel?: string
15
+ }
16
+
17
+ const VW = 400
18
+ const PAD = { t: 10, r: 8, b: 28, l: 36 }
19
+
20
+ export function StackedBarChart({
21
+ series,
22
+ labels = [],
23
+ height = 180,
24
+ normalized = false,
25
+ ariaLabel = 'Stacked bar chart',
26
+ }: StackedBarChartProps) {
27
+ const { stacks, gridLines, xLabels } = useMemo(() => {
28
+ if (!series.length || !series[0].values.length) {
29
+ return { stacks: [], gridLines: [], xLabels: [] }
30
+ }
31
+ const n = series[0].values.length
32
+ const chartW = VW - PAD.l - PAD.r
33
+ const chartH = height - PAD.t - PAD.b
34
+ const bot = PAD.t + chartH
35
+
36
+ const totals = Array.from({ length: n }, (_, i) =>
37
+ series.reduce((s, ser) => s + (ser.values[i] ?? 0), 0),
38
+ )
39
+ const maxTotal = normalized ? 1 : Math.max(...totals) || 1
40
+
41
+ const barW = (chartW / n) * 0.72
42
+ const gap = (chartW / n) * 0.28
43
+
44
+ const stacks = Array.from({ length: n }, (_, gi) => {
45
+ let y = bot
46
+ const total = totals[gi] || 1
47
+ return series.map((ser) => {
48
+ const raw = ser.values[gi] ?? 0
49
+ const v = normalized ? raw / total : raw
50
+ const bh = (v / maxTotal) * chartH
51
+ const rect = {
52
+ x: PAD.l + gi * (barW + gap) + gap / 2,
53
+ y: y - bh,
54
+ w: barW,
55
+ h: bh,
56
+ color: ser.color,
57
+ key: `${gi}-${ser.label}`,
58
+ }
59
+ y -= bh
60
+ return rect
61
+ })
62
+ })
63
+
64
+ const gridFracs = [0.25, 0.5, 0.75, 1]
65
+ const gridLines = gridFracs.map((f) => ({
66
+ y: PAD.t + (1 - f) * chartH,
67
+ label: normalized ? `${Math.round(f * 100)}%` : String(Math.round(f * maxTotal)),
68
+ }))
69
+
70
+ const xLabels = Array.from({ length: n }, (_, i) => ({
71
+ x: PAD.l + i * (barW + gap) + gap / 2 + barW / 2,
72
+ text: labels[i] ?? String(i),
73
+ }))
74
+
75
+ const yTicks = gridLines.map((g) => g)
76
+
77
+ return { stacks, gridLines, xLabels, yTicks }
78
+ }, [series, labels, height, normalized])
79
+
80
+ if (!stacks.length) return null
81
+
82
+ return (
83
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
84
+ <svg
85
+ viewBox={`0 0 ${VW} ${height}`}
86
+ style={{ width: '100%', height: 'auto', display: 'block' }}
87
+ aria-hidden="true"
88
+ focusable="false"
89
+ >
90
+ {gridLines.map((g, i) => (
91
+ <g key={i}>
92
+ <line
93
+ x1={PAD.l}
94
+ y1={g.y}
95
+ x2={VW - PAD.r}
96
+ y2={g.y}
97
+ stroke="rgba(255,255,255,0.06)"
98
+ strokeWidth={1}
99
+ />
100
+ <text
101
+ x={PAD.l - 4}
102
+ y={g.y + 3}
103
+ fill="#6E7079"
104
+ fontSize={8}
105
+ textAnchor="end"
106
+ fontFamily="inherit"
107
+ >
108
+ {g.label}
109
+ </text>
110
+ </g>
111
+ ))}
112
+
113
+ {stacks.map((rects) =>
114
+ rects.map((r) =>
115
+ r.h > 0 ? (
116
+ <rect
117
+ key={r.key}
118
+ x={r.x.toFixed(1)}
119
+ y={r.y.toFixed(1)}
120
+ width={r.w.toFixed(1)}
121
+ height={r.h.toFixed(1)}
122
+ fill={r.color}
123
+ fillOpacity={0.9}
124
+ />
125
+ ) : null,
126
+ ),
127
+ )}
128
+
129
+ {xLabels.map((l, i) => (
130
+ <text
131
+ key={i}
132
+ x={l.x.toFixed(1)}
133
+ y={height - 6}
134
+ fill="#6E7079"
135
+ fontSize={8}
136
+ textAnchor="middle"
137
+ fontFamily="inherit"
138
+ >
139
+ {l.text}
140
+ </text>
141
+ ))}
142
+ </svg>
143
+ </div>
144
+ )
145
+ }