@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,150 @@
1
+ export interface DendrogramNode {
2
+ id: string
3
+ label: string
4
+ children?: DendrogramNode[]
5
+ color?: string
6
+ }
7
+
8
+ export interface DendrogramProps {
9
+ root: DendrogramNode
10
+ colWidth?: number
11
+ rowHeight?: number
12
+ ariaLabel?: string
13
+ }
14
+
15
+ interface PlacedNode {
16
+ id: string
17
+ label: string
18
+ x: number
19
+ y: number
20
+ color: string
21
+ isLeaf: boolean
22
+ children: PlacedNode[]
23
+ }
24
+
25
+ const CHILD_COLORS = ['#006FFF', '#00C8C8', '#F5A623', '#7FB6FF', '#A878F5', '#F56342']
26
+
27
+ function placeNodes(
28
+ n: DendrogramNode,
29
+ depth: number,
30
+ colWidth: number,
31
+ rowHeight: number,
32
+ counter: { v: number },
33
+ color: string,
34
+ ): PlacedNode {
35
+ const nodeColor = n.color ?? color
36
+ const isLeaf = !n.children?.length
37
+ if (isLeaf) {
38
+ const y = counter.v * rowHeight + rowHeight / 2
39
+ counter.v++
40
+ return {
41
+ id: n.id,
42
+ label: n.label,
43
+ x: depth * colWidth,
44
+ y,
45
+ color: nodeColor,
46
+ isLeaf: true,
47
+ children: [],
48
+ }
49
+ }
50
+ const children = n.children!.map((c, i) =>
51
+ placeNodes(c, depth + 1, colWidth, rowHeight, counter, CHILD_COLORS[i % CHILD_COLORS.length]),
52
+ )
53
+ const y = (children[0].y + children[children.length - 1].y) / 2
54
+ return {
55
+ id: n.id,
56
+ label: n.label,
57
+ x: depth * colWidth,
58
+ y,
59
+ color: nodeColor,
60
+ isLeaf: false,
61
+ children,
62
+ }
63
+ }
64
+
65
+ function treeDepth(n: DendrogramNode): number {
66
+ if (!n.children?.length) return 0
67
+ return 1 + Math.max(...n.children.map(treeDepth))
68
+ }
69
+
70
+ function* iterNodes(n: PlacedNode): Generator<PlacedNode> {
71
+ yield n
72
+ for (const c of n.children) yield* iterNodes(c)
73
+ }
74
+
75
+ function* iterLinks(n: PlacedNode): Generator<[PlacedNode, PlacedNode]> {
76
+ for (const c of n.children) {
77
+ yield [n, c]
78
+ yield* iterLinks(c)
79
+ }
80
+ }
81
+
82
+ function shiftNode(n: PlacedNode, dx: number, dy: number): PlacedNode {
83
+ return { ...n, x: n.x + dx, y: n.y + dy, children: n.children.map((c) => shiftNode(c, dx, dy)) }
84
+ }
85
+
86
+ export function Dendrogram({
87
+ root,
88
+ colWidth = 130,
89
+ rowHeight = 28,
90
+ ariaLabel = 'Dendrogram',
91
+ }: DendrogramProps) {
92
+ const PAD_L = 12
93
+ const PAD_V = 10
94
+ const LABEL_LEAF_W = 130
95
+
96
+ const counter = { v: 0 }
97
+ const rawRoot = placeNodes(root, 0, colWidth, rowHeight, counter, '#8B909E')
98
+ const placed = shiftNode(rawRoot, PAD_L, PAD_V)
99
+
100
+ const maxDepth = treeDepth(root)
101
+ const svgW = PAD_L + (maxDepth + 1) * colWidth + LABEL_LEAF_W
102
+ const svgH = counter.v * rowHeight + PAD_V * 2
103
+
104
+ const nodes = [...iterNodes(placed)]
105
+ const links = [...iterLinks(placed)]
106
+
107
+ return (
108
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%', overflowX: 'auto' }}>
109
+ <svg
110
+ viewBox={`0 0 ${svgW} ${svgH}`}
111
+ style={{ width: '100%', height: svgH, display: 'block' }}
112
+ aria-hidden="true"
113
+ focusable="false"
114
+ >
115
+ {links.map(([parent, child], i) => {
116
+ const mx = (parent.x + child.x) / 2
117
+ const d = `M${parent.x},${parent.y} C${mx},${parent.y} ${mx},${child.y} ${child.x},${child.y}`
118
+ return (
119
+ <path key={i} d={d} fill="none" stroke="rgba(255,255,255,0.13)" strokeWidth={1.5} />
120
+ )
121
+ })}
122
+ {nodes.map((n) => (
123
+ <g key={n.id}>
124
+ <circle cx={n.x} cy={n.y} r={4} fill={n.color} />
125
+ {n.isLeaf ? (
126
+ <text x={n.x + 10} y={n.y + 4} fill="#C8CDD9" fontSize={10} fontFamily="inherit">
127
+ {n.label}
128
+ </text>
129
+ ) : n.children.length === 0 || n.x === PAD_L ? (
130
+ <text x={n.x + 10} y={n.y + 4} fill="#8B909E" fontSize={9} fontFamily="inherit">
131
+ {n.label}
132
+ </text>
133
+ ) : (
134
+ <text
135
+ x={n.x}
136
+ y={n.y - 7}
137
+ fill="#8B909E"
138
+ fontSize={9}
139
+ fontFamily="inherit"
140
+ textAnchor="middle"
141
+ >
142
+ {n.label}
143
+ </text>
144
+ )}
145
+ </g>
146
+ ))}
147
+ </svg>
148
+ </div>
149
+ )
150
+ }
@@ -0,0 +1,101 @@
1
+ export interface DonutSegment {
2
+ label: string
3
+ value: number
4
+ color: string
5
+ }
6
+
7
+ export interface DonutProps {
8
+ size?: number
9
+ segments: DonutSegment[]
10
+ centerValue: string | number
11
+ centerLabel: string
12
+ trackColor?: string
13
+ ariaLabel?: string
14
+ }
15
+
16
+ export function Donut({
17
+ size = 96,
18
+ segments,
19
+ centerValue,
20
+ centerLabel,
21
+ trackColor = 'rgba(255,255,255,0.06)',
22
+ ariaLabel,
23
+ }: DonutProps) {
24
+ const total = segments.reduce((s, x) => s + x.value, 0) || 1
25
+ const r = (size - 20) / 2
26
+ const c = 2 * Math.PI * r
27
+ let cumulative = 0
28
+ const segDesc = segments
29
+ .map((s) => `${s.label} ${Math.round((s.value / total) * 100)}%`)
30
+ .join(', ')
31
+ const label = ariaLabel ?? `${centerValue} ${centerLabel}${segDesc ? `: ${segDesc}` : ''}`
32
+ return (
33
+ <div
34
+ role="img"
35
+ aria-label={label}
36
+ style={{ position: 'relative', width: size, height: size, flexShrink: 0 }}
37
+ >
38
+ <svg
39
+ width={size}
40
+ height={size}
41
+ aria-hidden="true"
42
+ focusable="false"
43
+ style={{ transform: 'rotate(-90deg)' }}
44
+ >
45
+ <circle
46
+ cx={size / 2}
47
+ cy={size / 2}
48
+ r={r}
49
+ fill="none"
50
+ stroke={trackColor}
51
+ strokeWidth={10}
52
+ />
53
+ {segments.map((seg) => {
54
+ const frac = seg.value / total
55
+ const dash = c
56
+ const offset = c * (1 - cumulative - frac)
57
+ const rotate = cumulative * 360
58
+ cumulative += frac
59
+ return (
60
+ <circle
61
+ key={seg.label}
62
+ cx={size / 2}
63
+ cy={size / 2}
64
+ r={r}
65
+ fill="none"
66
+ stroke={seg.color}
67
+ strokeWidth={10}
68
+ strokeDasharray={dash}
69
+ strokeDashoffset={offset}
70
+ strokeLinecap="round"
71
+ transform={`rotate(${rotate} ${size / 2} ${size / 2})`}
72
+ />
73
+ )
74
+ })}
75
+ </svg>
76
+ <div
77
+ aria-hidden="true"
78
+ style={{
79
+ position: 'absolute',
80
+ inset: 0,
81
+ display: 'flex',
82
+ flexDirection: 'column',
83
+ alignItems: 'center',
84
+ justifyContent: 'center',
85
+ }}
86
+ >
87
+ <div
88
+ style={{
89
+ fontSize: 20,
90
+ fontWeight: 600,
91
+ letterSpacing: '-0.01em',
92
+ fontVariantNumeric: 'tabular-nums',
93
+ }}
94
+ >
95
+ {centerValue}
96
+ </div>
97
+ <div style={{ fontSize: 10, color: '#6E7079' }}>{centerLabel}</div>
98
+ </div>
99
+ </div>
100
+ )
101
+ }
@@ -0,0 +1,195 @@
1
+ export interface DotPlotItem {
2
+ label: string
3
+ value: number
4
+ compare?: number
5
+ color?: string
6
+ }
7
+
8
+ export interface DotPlotProps {
9
+ items: DotPlotItem[]
10
+ min?: number
11
+ max?: number
12
+ unit?: string
13
+ valueLegend?: string
14
+ compareLegend?: string
15
+ ariaLabel?: string
16
+ }
17
+
18
+ const VW = 380
19
+ const LABEL_W = 132
20
+ const VALUE_W = 32
21
+ const PAD_R = 6
22
+ const TRACK_W = VW - LABEL_W - VALUE_W - PAD_R
23
+ const DOT_R = 5
24
+ const ROW_H = 28
25
+ const PAD_T = 8
26
+ const AXIS_H = 18
27
+ const LEG_H = 18
28
+ const TICKS = 5
29
+
30
+ export function DotPlot({
31
+ items,
32
+ min = 0,
33
+ max,
34
+ unit = '',
35
+ valueLegend,
36
+ compareLegend,
37
+ ariaLabel = 'Dot plot',
38
+ }: DotPlotProps) {
39
+ const hasCompare = items.some((it) => it.compare !== undefined)
40
+ const maxVal = max ?? Math.max(...items.map((it) => Math.max(it.value, it.compare ?? 0)))
41
+ const range = Math.max(maxVal - min, 1)
42
+ const svgH =
43
+ PAD_T +
44
+ items.length * ROW_H +
45
+ AXIS_H +
46
+ (hasCompare && (valueLegend || compareLegend) ? LEG_H + 4 : 0)
47
+
48
+ const tx = (v: number) => LABEL_W + ((v - min) / range) * TRACK_W
49
+
50
+ const ticks = Array.from({ length: TICKS }, (_, i) => min + (i / (TICKS - 1)) * range)
51
+
52
+ const legY = PAD_T + items.length * ROW_H + AXIS_H + 4
53
+
54
+ return (
55
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
56
+ <svg
57
+ viewBox={`0 0 ${VW} ${svgH}`}
58
+ style={{ width: '100%', height: 'auto', display: 'block' }}
59
+ aria-hidden="true"
60
+ focusable="false"
61
+ >
62
+ {ticks.map((t, i) => (
63
+ <g key={i}>
64
+ <line
65
+ x1={tx(t)}
66
+ y1={PAD_T}
67
+ x2={tx(t)}
68
+ y2={PAD_T + items.length * ROW_H}
69
+ stroke="rgba(255,255,255,0.06)"
70
+ strokeWidth={1}
71
+ />
72
+ <text
73
+ x={tx(t)}
74
+ y={PAD_T + items.length * ROW_H + 13}
75
+ fill="#6E7079"
76
+ fontSize={9}
77
+ textAnchor="middle"
78
+ fontFamily="inherit"
79
+ >
80
+ {Number.isInteger(t) ? t : t.toFixed(1)}
81
+ {unit}
82
+ </text>
83
+ </g>
84
+ ))}
85
+
86
+ {items.map((item, i) => {
87
+ const cy = PAD_T + i * ROW_H + ROW_H / 2
88
+ const cx = tx(item.value)
89
+ const ccx = item.compare !== undefined ? tx(item.compare) : null
90
+ const color = item.color ?? '#006FFF'
91
+
92
+ return (
93
+ <g key={i}>
94
+ <text
95
+ x={LABEL_W - 8}
96
+ y={cy + 4}
97
+ fill="#A4A7B5"
98
+ fontSize={10}
99
+ textAnchor="end"
100
+ fontFamily="inherit"
101
+ >
102
+ {item.label}
103
+ </text>
104
+
105
+ <line
106
+ x1={LABEL_W}
107
+ y1={cy}
108
+ x2={LABEL_W + TRACK_W}
109
+ y2={cy}
110
+ stroke="rgba(255,255,255,0.06)"
111
+ strokeWidth={1}
112
+ />
113
+
114
+ {ccx !== null && (
115
+ <line
116
+ x1={Math.min(cx, ccx)}
117
+ y1={cy}
118
+ x2={Math.max(cx, ccx)}
119
+ y2={cy}
120
+ stroke={color}
121
+ strokeWidth={1.5}
122
+ opacity={0.35}
123
+ />
124
+ )}
125
+
126
+ {ccx !== null && (
127
+ <circle
128
+ cx={ccx}
129
+ cy={cy}
130
+ r={DOT_R - 1}
131
+ fill="none"
132
+ stroke="#A4A7B5"
133
+ strokeWidth={1.5}
134
+ />
135
+ )}
136
+
137
+ <circle cx={cx} cy={cy} r={DOT_R} fill={color} />
138
+
139
+ <text
140
+ x={LABEL_W + TRACK_W + 6}
141
+ y={cy + 4}
142
+ fill="#A4A7B5"
143
+ fontSize={9}
144
+ fontFamily="inherit"
145
+ >
146
+ {item.value}
147
+ {unit}
148
+ </text>
149
+ </g>
150
+ )
151
+ })}
152
+
153
+ {hasCompare && (valueLegend || compareLegend) && (
154
+ <g>
155
+ {valueLegend && (
156
+ <>
157
+ <circle cx={LABEL_W} cy={legY + 5} r={4} fill="#006FFF" />
158
+ <text
159
+ x={LABEL_W + 10}
160
+ y={legY + 9}
161
+ fill="#A4A7B5"
162
+ fontSize={9}
163
+ fontFamily="inherit"
164
+ >
165
+ {valueLegend}
166
+ </text>
167
+ </>
168
+ )}
169
+ {compareLegend && (
170
+ <>
171
+ <circle
172
+ cx={LABEL_W + (valueLegend ? 80 : 0)}
173
+ cy={legY + 5}
174
+ r={3}
175
+ fill="none"
176
+ stroke="#A4A7B5"
177
+ strokeWidth={1.5}
178
+ />
179
+ <text
180
+ x={LABEL_W + (valueLegend ? 90 : 10)}
181
+ y={legY + 9}
182
+ fill="#A4A7B5"
183
+ fontSize={9}
184
+ fontFamily="inherit"
185
+ >
186
+ {compareLegend}
187
+ </text>
188
+ </>
189
+ )}
190
+ </g>
191
+ )}
192
+ </svg>
193
+ </div>
194
+ )
195
+ }
@@ -0,0 +1,81 @@
1
+ import { useEffect, useId, useRef, type ReactNode } from 'react'
2
+ import { CloseIcon } from '../icons.js'
3
+ import { IconButton } from './Button.js'
4
+
5
+ export interface DrawerProps {
6
+ open: boolean
7
+ title: string
8
+ onClose: () => void
9
+ children?: ReactNode
10
+ }
11
+
12
+ const FOCUSABLE =
13
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
14
+
15
+ export function Drawer({ open, title, onClose, children }: DrawerProps) {
16
+ const titleId = useId()
17
+ const panelRef = useRef<HTMLDivElement>(null)
18
+
19
+ useEffect(() => {
20
+ if (!open) return
21
+ const prev = document.activeElement as HTMLElement | null
22
+ const panel = panelRef.current
23
+ const first = panel?.querySelector<HTMLElement>(FOCUSABLE)
24
+ ;(first ?? panel)?.focus()
25
+
26
+ function onKey(e: KeyboardEvent) {
27
+ if (e.key === 'Escape') {
28
+ onClose()
29
+ return
30
+ }
31
+ if (e.key !== 'Tab' || !panel) return
32
+ const items = Array.from(panel.querySelectorAll<HTMLElement>(FOCUSABLE))
33
+ if (!items.length) {
34
+ e.preventDefault()
35
+ return
36
+ }
37
+ const f = items[0]!,
38
+ l = items[items.length - 1]!
39
+ const active = document.activeElement
40
+ if (e.shiftKey && active === f) {
41
+ e.preventDefault()
42
+ l.focus()
43
+ } else if (!e.shiftKey && active === l) {
44
+ e.preventDefault()
45
+ f.focus()
46
+ }
47
+ }
48
+
49
+ window.addEventListener('keydown', onKey)
50
+ return () => {
51
+ window.removeEventListener('keydown', onKey)
52
+ prev?.focus?.()
53
+ }
54
+ }, [open, onClose])
55
+
56
+ return (
57
+ <>
58
+ <div
59
+ className={`drawer-overlay ${open ? 'show' : ''}`}
60
+ onClick={onClose}
61
+ aria-hidden="true"
62
+ />
63
+ <div
64
+ ref={panelRef}
65
+ className={`drawer-panel ${open ? 'show' : ''}`}
66
+ role="dialog"
67
+ aria-modal="true"
68
+ aria-labelledby={titleId}
69
+ tabIndex={-1}
70
+ >
71
+ <div className="drawer-h">
72
+ <h2 id={titleId}>{title}</h2>
73
+ <IconButton onClick={onClose} aria-label="Close">
74
+ <CloseIcon />
75
+ </IconButton>
76
+ </div>
77
+ <div className="drawer-b">{children}</div>
78
+ </div>
79
+ </>
80
+ )
81
+ }