@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.
- package/README.md +9 -0
- package/package.json +59 -0
- package/src/components/Accordion.tsx +65 -0
- package/src/components/ActionMenu.tsx +131 -0
- package/src/components/ActivityFeed.tsx +77 -0
- package/src/components/Alert.tsx +33 -0
- package/src/components/AnnotatedTimeSeries.tsx +148 -0
- package/src/components/AppShell.tsx +158 -0
- package/src/components/ArcDiagram.tsx +114 -0
- package/src/components/AreaChart.tsx +101 -0
- package/src/components/Avatar.tsx +37 -0
- package/src/components/AvatarGroup.tsx +36 -0
- package/src/components/Badge.tsx +36 -0
- package/src/components/Banner.tsx +49 -0
- package/src/components/BarChart.tsx +106 -0
- package/src/components/BeeswarmChart.tsx +142 -0
- package/src/components/BoxPlot.tsx +142 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/BubbleChart.tsx +115 -0
- package/src/components/BulletChart.tsx +121 -0
- package/src/components/BumpChart.tsx +127 -0
- package/src/components/Button.tsx +61 -0
- package/src/components/CIDRInput.tsx +191 -0
- package/src/components/CalendarHeatmap.tsx +117 -0
- package/src/components/Callout.tsx +82 -0
- package/src/components/CandlestickChart.tsx +113 -0
- package/src/components/Card.tsx +27 -0
- package/src/components/Carousel.tsx +78 -0
- package/src/components/Checkbox.tsx +42 -0
- package/src/components/ChordDiagram.tsx +133 -0
- package/src/components/CirclePacking.tsx +189 -0
- package/src/components/CodeBlock.tsx +27 -0
- package/src/components/ColorPicker.tsx +120 -0
- package/src/components/ColumnToggle.tsx +106 -0
- package/src/components/Combobox.tsx +211 -0
- package/src/components/CommandPalette.tsx +168 -0
- package/src/components/ConfirmDialog.tsx +114 -0
- package/src/components/ContextMenu.tsx +127 -0
- package/src/components/ContextualHelp.tsx +69 -0
- package/src/components/ContourPlot.tsx +255 -0
- package/src/components/CopyButton.tsx +47 -0
- package/src/components/CorrelationMatrix.tsx +107 -0
- package/src/components/CountUp.tsx +70 -0
- package/src/components/CumulativeDistribution.tsx +155 -0
- package/src/components/DatePicker.tsx +315 -0
- package/src/components/DateRangePicker.tsx +327 -0
- package/src/components/Dendrogram.tsx +150 -0
- package/src/components/Donut.tsx +101 -0
- package/src/components/DotPlot.tsx +195 -0
- package/src/components/Drawer.tsx +81 -0
- package/src/components/DualAxisChart.tsx +209 -0
- package/src/components/DumbbellChart.tsx +102 -0
- package/src/components/DurationInput.tsx +144 -0
- package/src/components/EmptyState.tsx +40 -0
- package/src/components/ErrorBandChart.tsx +124 -0
- package/src/components/ExpandableRow.tsx +58 -0
- package/src/components/FileUpload.tsx +129 -0
- package/src/components/FilterBuilder.tsx +217 -0
- package/src/components/FlameGraph.tsx +119 -0
- package/src/components/ForceGraph.tsx +149 -0
- package/src/components/FunnelChart.tsx +79 -0
- package/src/components/GanttChart.tsx +96 -0
- package/src/components/Gauge.tsx +71 -0
- package/src/components/GroupedList.tsx +125 -0
- package/src/components/HealthBar.tsx +25 -0
- package/src/components/HeatMap.tsx +106 -0
- package/src/components/HexbinChart.tsx +152 -0
- package/src/components/Histogram.tsx +121 -0
- package/src/components/HorizonChart.tsx +110 -0
- package/src/components/HoverCard.tsx +52 -0
- package/src/components/IPInput.tsx +147 -0
- package/src/components/IcicleChart.tsx +118 -0
- package/src/components/InlineEdit.tsx +119 -0
- package/src/components/Input.tsx +94 -0
- package/src/components/InputGroup.tsx +21 -0
- package/src/components/JsonViewer.tsx +116 -0
- package/src/components/KVTable.tsx +29 -0
- package/src/components/KanbanBoard.tsx +171 -0
- package/src/components/Kbd.tsx +22 -0
- package/src/components/LineChart.tsx +112 -0
- package/src/components/LogViewer.tsx +102 -0
- package/src/components/LollipopChart.tsx +110 -0
- package/src/components/MACInput.tsx +164 -0
- package/src/components/MarimekkoChart.tsx +143 -0
- package/src/components/MatrixChart.tsx +118 -0
- package/src/components/Menubar.tsx +194 -0
- package/src/components/MirroredBarChart.tsx +148 -0
- package/src/components/Modal.tsx +87 -0
- package/src/components/MultiSelect.tsx +232 -0
- package/src/components/NightingaleChart.tsx +101 -0
- package/src/components/NotificationPanel.tsx +187 -0
- package/src/components/NumberInput.tsx +129 -0
- package/src/components/OTPInput.tsx +135 -0
- package/src/components/Pagination.tsx +73 -0
- package/src/components/ParallelCoordinates.tsx +149 -0
- package/src/components/ParetoChart.tsx +159 -0
- package/src/components/PasswordInput.tsx +112 -0
- package/src/components/PieChart.tsx +109 -0
- package/src/components/Pill.tsx +26 -0
- package/src/components/PolarHeatmap.tsx +118 -0
- package/src/components/Popover.tsx +103 -0
- package/src/components/ProgressBar.tsx +33 -0
- package/src/components/PunchCard.tsx +93 -0
- package/src/components/QuadrantChart.tsx +256 -0
- package/src/components/RadarChart.tsx +119 -0
- package/src/components/RadialBarChart.tsx +103 -0
- package/src/components/RadioGroup.tsx +69 -0
- package/src/components/RangeSlider.tsx +116 -0
- package/src/components/RankedList.tsx +61 -0
- package/src/components/ResizablePanel.tsx +122 -0
- package/src/components/RidgelinePlot.tsx +155 -0
- package/src/components/SankeyDiagram.tsx +156 -0
- package/src/components/ScatterPlot.tsx +96 -0
- package/src/components/SegmentedControl.tsx +78 -0
- package/src/components/Select.tsx +158 -0
- package/src/components/SelectionToolbar.tsx +55 -0
- package/src/components/Signal.tsx +19 -0
- package/src/components/Skeleton.tsx +37 -0
- package/src/components/SkipLink.tsx +19 -0
- package/src/components/Slider.tsx +74 -0
- package/src/components/SlopeChart.tsx +205 -0
- package/src/components/SortableList.tsx +130 -0
- package/src/components/SortableTable.tsx +46 -0
- package/src/components/Sparkline.tsx +50 -0
- package/src/components/SparklineMatrix.tsx +117 -0
- package/src/components/Spinner.tsx +8 -0
- package/src/components/SplitButton.tsx +151 -0
- package/src/components/Spoiler.tsx +47 -0
- package/src/components/StackedBarChart.tsx +145 -0
- package/src/components/StackedProgress.tsx +47 -0
- package/src/components/StarRating.tsx +103 -0
- package/src/components/Stat.tsx +48 -0
- package/src/components/StatusIndicator.tsx +26 -0
- package/src/components/StepChart.tsx +130 -0
- package/src/components/Stepper.tsx +51 -0
- package/src/components/StreamGraph.tsx +97 -0
- package/src/components/StripeChart.tsx +76 -0
- package/src/components/SunburstChart.tsx +142 -0
- package/src/components/SwitchPortGrid.tsx +59 -0
- package/src/components/Tabs.tsx +91 -0
- package/src/components/Tag.tsx +37 -0
- package/src/components/TagInput.tsx +99 -0
- package/src/components/Textarea.tsx +9 -0
- package/src/components/ThresholdAreaChart.tsx +113 -0
- package/src/components/TimePicker.tsx +144 -0
- package/src/components/TimeRange.tsx +152 -0
- package/src/components/Timeline.tsx +31 -0
- package/src/components/Toast.tsx +72 -0
- package/src/components/Toggle.tsx +68 -0
- package/src/components/ToggleGroup.tsx +57 -0
- package/src/components/Tooltip.tsx +25 -0
- package/src/components/TransferList.tsx +227 -0
- package/src/components/TreeMap.tsx +126 -0
- package/src/components/TreeView.tsx +221 -0
- package/src/components/UptimeTimeline.tsx +138 -0
- package/src/components/VennDiagram.tsx +151 -0
- package/src/components/ViolinPlot.tsx +158 -0
- package/src/components/VirtualList.tsx +59 -0
- package/src/components/WaffleChart.tsx +79 -0
- package/src/components/WaterfallChart.tsx +164 -0
- package/src/components/WordCloud.tsx +107 -0
- package/src/icons.tsx +241 -0
- package/src/index.ts +160 -0
- 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
|
+
}
|