@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,164 @@
1
+ import { useId, useRef, useState } from 'react'
2
+ import type { ClipboardEvent, KeyboardEvent, ChangeEvent } from 'react'
3
+
4
+ export interface MACInputProps {
5
+ label?: string
6
+ value?: string
7
+ defaultValue?: string
8
+ disabled?: boolean
9
+ onChange?: (value: string) => void
10
+ id?: string
11
+ className?: string
12
+ }
13
+
14
+ const HEX_RE = /^[0-9a-fA-F]{0,2}$/
15
+ const FULL_MAC_RE = /^([0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}$/
16
+
17
+ function parseMAC(mac: string): [string, string, string, string, string, string] {
18
+ const parts = mac.replace(/-/g, ':').split(':')
19
+ return [
20
+ parts[0] ?? '00',
21
+ parts[1] ?? '00',
22
+ parts[2] ?? '00',
23
+ parts[3] ?? '00',
24
+ parts[4] ?? '00',
25
+ parts[5] ?? '00',
26
+ ]
27
+ }
28
+
29
+ function normalizeMAC(mac: string): string {
30
+ return (
31
+ mac
32
+ .replace(/-/g, ':')
33
+ .replace(/:/g, '')
34
+ .match(/.{1,2}/g)
35
+ ?.join(':') ?? mac
36
+ )
37
+ }
38
+
39
+ export function MACInput({
40
+ label,
41
+ value: valueProp,
42
+ defaultValue = '00:00:00:00:00:00',
43
+ disabled = false,
44
+ onChange,
45
+ id,
46
+ className = '',
47
+ }: MACInputProps) {
48
+ const gid = useId()
49
+ const inputId = id ?? gid
50
+ const [internal, setInternal] = useState<[string, string, string, string, string, string]>(() =>
51
+ parseMAC(valueProp ?? defaultValue),
52
+ )
53
+ const refs = useRef<(HTMLInputElement | null)[]>([null, null, null, null, null, null])
54
+
55
+ const pairs = valueProp !== undefined ? parseMAC(valueProp) : internal
56
+
57
+ function focusAt(i: number) {
58
+ const el = refs.current[i]
59
+ if (el) {
60
+ el.focus()
61
+ el.select()
62
+ }
63
+ }
64
+
65
+ function commit(i: number, val: string) {
66
+ const next = [...pairs] as [string, string, string, string, string, string]
67
+ next[i] = val.toUpperCase()
68
+ if (valueProp === undefined) setInternal(next)
69
+ onChange?.(next.join(':'))
70
+ }
71
+
72
+ function handleChange(i: number, e: ChangeEvent<HTMLInputElement>) {
73
+ const raw = e.target.value
74
+ if (!HEX_RE.test(raw)) return
75
+ commit(i, raw)
76
+ if (raw.length === 2 && i < 5) focusAt(i + 1)
77
+ }
78
+
79
+ function handleKey(i: number, e: KeyboardEvent<HTMLInputElement>) {
80
+ const el = refs.current[i]!
81
+ if (e.key === ':' || e.key === '-') {
82
+ e.preventDefault()
83
+ if (i < 5) focusAt(i + 1)
84
+ } else if (e.key === 'ArrowRight' && el.selectionStart === el.value.length) {
85
+ if (i < 5) {
86
+ e.preventDefault()
87
+ focusAt(i + 1)
88
+ }
89
+ } else if (e.key === 'ArrowLeft' && el.selectionStart === 0) {
90
+ if (i > 0) {
91
+ e.preventDefault()
92
+ focusAt(i - 1)
93
+ }
94
+ } else if (e.key === 'Backspace' && el.value === '' && i > 0) {
95
+ e.preventDefault()
96
+ focusAt(i - 1)
97
+ }
98
+ }
99
+
100
+ function handlePaste(e: ClipboardEvent<HTMLInputElement>) {
101
+ const text = e.clipboardData.getData('text').trim()
102
+ if (FULL_MAC_RE.test(text)) {
103
+ e.preventDefault()
104
+ const normalized = normalizeMAC(text)
105
+ const next = normalized.split(':') as [string, string, string, string, string, string]
106
+ const upper = next.map((p) => p.toUpperCase()) as [
107
+ string,
108
+ string,
109
+ string,
110
+ string,
111
+ string,
112
+ string,
113
+ ]
114
+ if (valueProp === undefined) setInternal(upper)
115
+ onChange?.(upper.join(':'))
116
+ focusAt(5)
117
+ }
118
+ }
119
+
120
+ return (
121
+ <div className={`mac-input-wrapper ${className}`.trim()}>
122
+ {label && (
123
+ <label htmlFor={`${inputId}-0`} className="mac-input__label">
124
+ {label}
125
+ </label>
126
+ )}
127
+ <div
128
+ className={`mac-input${disabled ? ' mac-input--disabled' : ''}`}
129
+ role="group"
130
+ aria-label={label ?? 'MAC address'}
131
+ >
132
+ {pairs.flatMap((pair, i) => {
133
+ const nodes = [
134
+ <input
135
+ key={`p${i}`}
136
+ ref={(el) => {
137
+ refs.current[i] = el
138
+ }}
139
+ id={i === 0 ? `${inputId}-0` : undefined}
140
+ type="text"
141
+ inputMode="text"
142
+ aria-label={`Byte ${i + 1} of 6`}
143
+ value={pair}
144
+ disabled={disabled}
145
+ maxLength={2}
146
+ className="mac-input__pair"
147
+ onChange={(e) => handleChange(i, e)}
148
+ onKeyDown={(e) => handleKey(i, e)}
149
+ onPaste={handlePaste}
150
+ onFocus={(e) => e.currentTarget.select()}
151
+ />,
152
+ ]
153
+ if (i < 5)
154
+ nodes.push(
155
+ <span key={`c${i}`} className="mac-input__colon" aria-hidden="true">
156
+ :
157
+ </span>,
158
+ )
159
+ return nodes
160
+ })}
161
+ </div>
162
+ </div>
163
+ )
164
+ }
@@ -0,0 +1,143 @@
1
+ import { useMemo } from 'react'
2
+
3
+ export interface MarimekkoSegment {
4
+ label: string
5
+ value: number
6
+ color?: string
7
+ }
8
+
9
+ export interface MarimekkoColumn {
10
+ label: string
11
+ segments: MarimekkoSegment[]
12
+ }
13
+
14
+ export interface MarimekkoChartProps {
15
+ columns: MarimekkoColumn[]
16
+ height?: number
17
+ ariaLabel?: string
18
+ }
19
+
20
+ const PALETTE = ['#006FFF', '#00C875', '#F5A623', '#A78BFA', '#FF7B7B', '#00C8C8', '#FB923C']
21
+ const VW = 340
22
+ const LABEL_H = 22
23
+ const GAP = 2
24
+
25
+ interface Cell {
26
+ x: number
27
+ y: number
28
+ w: number
29
+ h: number
30
+ color: string
31
+ label: string
32
+ }
33
+ interface Header {
34
+ x: number
35
+ w: number
36
+ label: string
37
+ }
38
+
39
+ export function MarimekkoChart({
40
+ columns,
41
+ height = 160,
42
+ ariaLabel = 'Marimekko chart',
43
+ }: MarimekkoChartProps) {
44
+ const { cells, headers } = useMemo(() => {
45
+ const colTotals = columns.map((col) => col.segments.reduce((s, seg) => s + seg.value, 0))
46
+ const grandTotal = colTotals.reduce((s, t) => s + t, 0)
47
+ if (grandTotal === 0) return { cells: [] as Cell[], headers: [] as Header[] }
48
+
49
+ const chartH = height - LABEL_H
50
+ const result: Cell[] = []
51
+ const hdrs: Header[] = []
52
+
53
+ let cx = 0
54
+ columns.forEach((col, ci) => {
55
+ const colTotal = colTotals[ci]
56
+ const colW = (colTotal / grandTotal) * VW
57
+ hdrs.push({ x: cx, w: colW, label: col.label })
58
+
59
+ let sy = LABEL_H
60
+ col.segments.forEach((seg, si) => {
61
+ const segH = colTotal > 0 ? (seg.value / colTotal) * chartH : 0
62
+ result.push({
63
+ x: cx,
64
+ y: sy,
65
+ w: colW,
66
+ h: segH,
67
+ color: seg.color ?? PALETTE[si % PALETTE.length],
68
+ label: seg.label,
69
+ })
70
+ sy += segH
71
+ })
72
+ cx += colW
73
+ })
74
+ return { cells: result, headers: hdrs }
75
+ }, [columns, height])
76
+
77
+ if (!cells.length) return null
78
+
79
+ return (
80
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
81
+ <svg
82
+ viewBox={`0 0 ${VW} ${height}`}
83
+ style={{ width: '100%', height: 'auto', display: 'block' }}
84
+ aria-hidden="true"
85
+ focusable="false"
86
+ >
87
+ {headers.map((h, i) =>
88
+ h.w > GAP * 2 ? (
89
+ <text
90
+ key={i}
91
+ x={h.x + h.w / 2}
92
+ y={LABEL_H - 6}
93
+ textAnchor="middle"
94
+ fontSize={Math.min(10, h.w / 6)}
95
+ fill="#A4A7B5"
96
+ fontFamily="inherit"
97
+ >
98
+ {h.label}
99
+ </text>
100
+ ) : null,
101
+ )}
102
+ {cells.map((cell, i) =>
103
+ cell.w > GAP * 2 && cell.h > GAP * 2 ? (
104
+ <g key={i}>
105
+ <rect
106
+ x={cell.x + GAP / 2}
107
+ y={cell.y + GAP / 2}
108
+ width={Math.max(0, cell.w - GAP)}
109
+ height={Math.max(0, cell.h - GAP)}
110
+ fill={cell.color}
111
+ fillOpacity={0.8}
112
+ rx={2}
113
+ />
114
+ {cell.w > 36 && cell.h > 14 && (
115
+ <text
116
+ x={cell.x + cell.w / 2}
117
+ y={cell.y + cell.h / 2 + 4}
118
+ textAnchor="middle"
119
+ fontSize={Math.min(9, (cell.w - GAP) / 8, cell.h - 6)}
120
+ fill="#fff"
121
+ fontFamily="inherit"
122
+ >
123
+ {cell.label}
124
+ </text>
125
+ )}
126
+ </g>
127
+ ) : null,
128
+ )}
129
+ {headers.slice(0, -1).map((h, i) => (
130
+ <line
131
+ key={i}
132
+ x1={h.x + h.w}
133
+ y1={LABEL_H}
134
+ x2={h.x + h.w}
135
+ y2={height}
136
+ stroke="rgba(255,255,255,0.12)"
137
+ strokeWidth={1}
138
+ />
139
+ ))}
140
+ </svg>
141
+ </div>
142
+ )
143
+ }
@@ -0,0 +1,118 @@
1
+ export interface MatrixChartProps {
2
+ rows: string[]
3
+ cols: string[]
4
+ values: number[][]
5
+ unit?: string
6
+ height?: number
7
+ ariaLabel?: string
8
+ }
9
+
10
+ const LEFT = 80
11
+ const TOP = 60
12
+ const CELL = 38
13
+ const FONT = 'inherit'
14
+
15
+ function cellColor(t: number): string {
16
+ const alpha = 0.07 + t * 0.88
17
+ return `rgba(0,111,255,${alpha.toFixed(2)})`
18
+ }
19
+
20
+ function fmt(n: number): string {
21
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
22
+ return String(n)
23
+ }
24
+
25
+ export function MatrixChart({
26
+ rows,
27
+ cols,
28
+ values,
29
+ unit = '',
30
+ ariaLabel = 'Matrix chart',
31
+ }: MatrixChartProps) {
32
+ const allVals = values.flat()
33
+ const max = Math.max(...allVals, 1)
34
+ const W = LEFT + cols.length * CELL
35
+ const H = TOP + rows.length * CELL
36
+
37
+ return (
38
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
39
+ <svg
40
+ viewBox={`0 0 ${W} ${H}`}
41
+ style={{ width: '100%', height: 'auto', display: 'block' }}
42
+ aria-hidden="true"
43
+ focusable="false"
44
+ >
45
+ {cols.map((col, ci) => {
46
+ const cx = LEFT + ci * CELL + CELL / 2
47
+ return (
48
+ <text
49
+ key={ci}
50
+ x={cx}
51
+ y={TOP - 8}
52
+ fill="#A4A7B5"
53
+ fontSize={10}
54
+ textAnchor="end"
55
+ fontFamily={FONT}
56
+ transform={`rotate(-40, ${cx}, ${TOP - 8})`}
57
+ >
58
+ {col}
59
+ </text>
60
+ )
61
+ })}
62
+
63
+ {rows.map((row, ri) => {
64
+ const cy = TOP + ri * CELL + CELL / 2
65
+ return (
66
+ <text
67
+ key={ri}
68
+ x={LEFT - 8}
69
+ y={cy + 4}
70
+ fill="#A4A7B5"
71
+ fontSize={10}
72
+ textAnchor="end"
73
+ fontFamily={FONT}
74
+ >
75
+ {row}
76
+ </text>
77
+ )
78
+ })}
79
+
80
+ {rows.map((_, ri) =>
81
+ cols.map((_, ci) => {
82
+ const v = values[ri]?.[ci] ?? 0
83
+ const t = v / max
84
+ const x = LEFT + ci * CELL
85
+ const y = TOP + ri * CELL
86
+ const cx = x + CELL / 2
87
+ const cy = y + CELL / 2
88
+ return (
89
+ <g key={`${ri}-${ci}`}>
90
+ <rect
91
+ x={x + 1}
92
+ y={y + 1}
93
+ width={CELL - 2}
94
+ height={CELL - 2}
95
+ rx={3}
96
+ fill={cellColor(t)}
97
+ />
98
+ {v > 0 && (
99
+ <text
100
+ x={cx}
101
+ y={cy + 4}
102
+ fill={t > 0.5 ? '#fff' : '#A4A7B5'}
103
+ fontSize={9}
104
+ textAnchor="middle"
105
+ fontFamily={FONT}
106
+ >
107
+ {fmt(v)}
108
+ {unit}
109
+ </text>
110
+ )}
111
+ </g>
112
+ )
113
+ }),
114
+ )}
115
+ </svg>
116
+ </div>
117
+ )
118
+ }
@@ -0,0 +1,194 @@
1
+ import { useState, useRef, useEffect, useId, useCallback } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+
4
+ export interface MenubarItem {
5
+ id: string
6
+ label: string
7
+ disabled?: boolean
8
+ separator?: boolean
9
+ }
10
+
11
+ export interface MenubarMenu {
12
+ id: string
13
+ label: string
14
+ items: MenubarItem[]
15
+ }
16
+
17
+ export interface MenubarProps {
18
+ menus: MenubarMenu[]
19
+ onAction: (menuId: string, itemId: string) => void
20
+ label?: string
21
+ className?: string
22
+ }
23
+
24
+ export function Menubar({ menus, onAction, label = 'Menu', className }: MenubarProps) {
25
+ const uid = useId()
26
+ const [openIdx, setOpenIdx] = useState<number | null>(null)
27
+ const [activeItemIdx, setActiveItemIdx] = useState(0)
28
+ const triggerRefs = useRef<(HTMLButtonElement | null)[]>([])
29
+ const rootRef = useRef<HTMLDivElement>(null)
30
+
31
+ useEffect(() => {
32
+ if (openIdx === null) return
33
+ function handler(e: MouseEvent) {
34
+ if (!rootRef.current?.contains(e.target as Node)) setOpenIdx(null)
35
+ }
36
+ document.addEventListener('mousedown', handler)
37
+ return () => document.removeEventListener('mousedown', handler)
38
+ }, [openIdx])
39
+
40
+ const openMenu = useCallback(
41
+ (idx: number, focusLast = false) => {
42
+ const eligible = menus[idx].items.filter((i) => !i.separator && !i.disabled)
43
+ setActiveItemIdx(focusLast ? eligible.length - 1 : 0)
44
+ setOpenIdx(idx)
45
+ },
46
+ [menus],
47
+ )
48
+
49
+ const closeMenu = useCallback(() => setOpenIdx(null), [])
50
+
51
+ const activate = useCallback(
52
+ (menuId: string, itemId: string) => {
53
+ onAction(menuId, itemId)
54
+ setOpenIdx(null)
55
+ },
56
+ [onAction],
57
+ )
58
+
59
+ function handleTriggerKeyDown(e: KeyboardEvent, idx: number) {
60
+ const count = menus.length
61
+ if (openIdx === null) {
62
+ if (e.key === 'ArrowRight') {
63
+ e.preventDefault()
64
+ triggerRefs.current[(idx + 1) % count]?.focus()
65
+ } else if (e.key === 'ArrowLeft') {
66
+ e.preventDefault()
67
+ triggerRefs.current[(idx - 1 + count) % count]?.focus()
68
+ } else if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
69
+ e.preventDefault()
70
+ openMenu(idx, false)
71
+ } else if (e.key === 'ArrowUp') {
72
+ e.preventDefault()
73
+ openMenu(idx, true)
74
+ } else if (e.key === 'Home') {
75
+ e.preventDefault()
76
+ triggerRefs.current[0]?.focus()
77
+ } else if (e.key === 'End') {
78
+ e.preventDefault()
79
+ triggerRefs.current[count - 1]?.focus()
80
+ }
81
+ } else {
82
+ const menu = menus[openIdx]
83
+ const eligible = menu.items.filter((i) => !i.separator && !i.disabled)
84
+ if (e.key === 'Escape') {
85
+ e.preventDefault()
86
+ closeMenu()
87
+ triggerRefs.current[openIdx]?.focus()
88
+ } else if (e.key === 'ArrowDown') {
89
+ e.preventDefault()
90
+ setActiveItemIdx((i) => Math.min(i + 1, eligible.length - 1))
91
+ } else if (e.key === 'ArrowUp') {
92
+ e.preventDefault()
93
+ setActiveItemIdx((i) => Math.max(i - 1, 0))
94
+ } else if (e.key === 'Home') {
95
+ e.preventDefault()
96
+ setActiveItemIdx(0)
97
+ } else if (e.key === 'End') {
98
+ e.preventDefault()
99
+ setActiveItemIdx(eligible.length - 1)
100
+ } else if (e.key === 'Enter' || e.key === ' ') {
101
+ e.preventDefault()
102
+ const item = eligible[activeItemIdx]
103
+ if (item) activate(menu.id, item.id)
104
+ } else if (e.key === 'ArrowRight') {
105
+ e.preventDefault()
106
+ const nextIdx = (openIdx + 1) % count
107
+ openMenu(nextIdx, false)
108
+ triggerRefs.current[nextIdx]?.focus()
109
+ } else if (e.key === 'ArrowLeft') {
110
+ e.preventDefault()
111
+ const prevIdx = (openIdx - 1 + count) % count
112
+ openMenu(prevIdx, false)
113
+ triggerRefs.current[prevIdx]?.focus()
114
+ } else if (e.key === 'Tab') {
115
+ closeMenu()
116
+ }
117
+ }
118
+ }
119
+
120
+ return (
121
+ <div
122
+ ref={rootRef}
123
+ role="menubar"
124
+ aria-label={label}
125
+ className={`menubar${className ? ' ' + className : ''}`}
126
+ >
127
+ {menus.map((menu, idx) => {
128
+ const isOpen = openIdx === idx
129
+ const menuId = `${uid}-menu-${idx}`
130
+ const eligible = menu.items.filter((i) => !i.separator && !i.disabled)
131
+ return (
132
+ <div key={menu.id} className="menubar-menu">
133
+ <button
134
+ ref={(el) => {
135
+ triggerRefs.current[idx] = el
136
+ }}
137
+ type="button"
138
+ role="menuitem"
139
+ aria-haspopup="menu"
140
+ aria-expanded={isOpen}
141
+ aria-controls={isOpen ? menuId : undefined}
142
+ className={`menubar-trigger${isOpen ? ' is-open' : ''}`}
143
+ onClick={() => {
144
+ if (isOpen) closeMenu()
145
+ else openMenu(idx)
146
+ }}
147
+ onKeyDown={(e) => handleTriggerKeyDown(e, idx)}
148
+ >
149
+ {menu.label}
150
+ </button>
151
+ {isOpen && (
152
+ <ul id={menuId} role="menu" aria-label={menu.label} className="menubar-dropdown">
153
+ {menu.items.map((item) => {
154
+ if (item.separator) {
155
+ return (
156
+ <li
157
+ key={item.id}
158
+ role="separator"
159
+ className="menubar-sep"
160
+ aria-hidden="true"
161
+ />
162
+ )
163
+ }
164
+ const eligibleIdx = eligible.indexOf(item)
165
+ return (
166
+ <li
167
+ key={item.id}
168
+ role="menuitem"
169
+ tabIndex={-1}
170
+ aria-disabled={item.disabled}
171
+ data-active={
172
+ eligibleIdx === activeItemIdx && !item.disabled ? 'true' : undefined
173
+ }
174
+ className="menubar-item"
175
+ onMouseEnter={() => {
176
+ if (!item.disabled) setActiveItemIdx(eligibleIdx)
177
+ }}
178
+ onMouseDown={(e) => {
179
+ e.preventDefault()
180
+ if (!item.disabled) activate(menu.id, item.id)
181
+ }}
182
+ >
183
+ {item.label}
184
+ </li>
185
+ )
186
+ })}
187
+ </ul>
188
+ )}
189
+ </div>
190
+ )
191
+ })}
192
+ </div>
193
+ )
194
+ }