@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,148 @@
1
+ export interface MirroredBarItem {
2
+ label: string
3
+ left: number
4
+ right: number
5
+ }
6
+
7
+ export interface MirroredBarChartProps {
8
+ items: MirroredBarItem[]
9
+ leftLabel?: string
10
+ rightLabel?: string
11
+ leftColor?: string
12
+ rightColor?: string
13
+ unit?: string
14
+ ariaLabel?: string
15
+ }
16
+
17
+ const VW = 340
18
+ const LABEL_W = 80
19
+ const PAD_T = 20
20
+ const PAD_B = 8
21
+ const ROW_H = 26
22
+ const HALF_W = (VW - LABEL_W) / 2
23
+ const CX = VW / 2
24
+
25
+ export function MirroredBarChart({
26
+ items,
27
+ leftLabel = 'Download',
28
+ rightLabel = 'Upload',
29
+ leftColor = '#006FFF',
30
+ rightColor = '#00C8C8',
31
+ unit = '',
32
+ ariaLabel = 'Mirrored bar chart',
33
+ }: MirroredBarChartProps) {
34
+ const svgH = PAD_T + items.length * ROW_H + PAD_B
35
+ const maxVal = Math.max(...items.flatMap((it) => [it.left, it.right]), 1)
36
+
37
+ function bw(val: number): number {
38
+ return (val / maxVal) * HALF_W
39
+ }
40
+
41
+ return (
42
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
43
+ <svg
44
+ viewBox={`0 0 ${VW} ${svgH}`}
45
+ style={{ width: '100%', height: 'auto', display: 'block' }}
46
+ aria-hidden="true"
47
+ focusable="false"
48
+ >
49
+ <text
50
+ x={CX - LABEL_W / 2 - 6}
51
+ y={13}
52
+ fill="#6E7079"
53
+ fontSize={9}
54
+ textAnchor="end"
55
+ fontFamily="inherit"
56
+ >
57
+ {leftLabel}
58
+ </text>
59
+ <text
60
+ x={CX + LABEL_W / 2 + 6}
61
+ y={13}
62
+ fill="#6E7079"
63
+ fontSize={9}
64
+ textAnchor="start"
65
+ fontFamily="inherit"
66
+ >
67
+ {rightLabel}
68
+ </text>
69
+
70
+ <line
71
+ x1={CX}
72
+ y1={PAD_T - 4}
73
+ x2={CX}
74
+ y2={PAD_T + items.length * ROW_H}
75
+ stroke="rgba(255,255,255,0.12)"
76
+ strokeWidth={1}
77
+ />
78
+
79
+ {items.map((item, i) => {
80
+ const y = PAD_T + i * ROW_H
81
+ const midY = y + ROW_H / 2
82
+ const bh = ROW_H - 8
83
+ const by = y + 4
84
+ const lw = bw(item.left)
85
+ const rw = bw(item.right)
86
+ return (
87
+ <g key={i}>
88
+ <rect
89
+ x={CX - LABEL_W / 2 - lw}
90
+ y={by}
91
+ width={lw}
92
+ height={bh}
93
+ fill={leftColor}
94
+ opacity={0.85}
95
+ rx={2}
96
+ />
97
+ <rect
98
+ x={CX + LABEL_W / 2}
99
+ y={by}
100
+ width={rw}
101
+ height={bh}
102
+ fill={rightColor}
103
+ opacity={0.85}
104
+ rx={2}
105
+ />
106
+ <text
107
+ x={CX}
108
+ y={midY + 4}
109
+ fill="#CDD0DB"
110
+ fontSize={10}
111
+ textAnchor="middle"
112
+ fontFamily="inherit"
113
+ >
114
+ {item.label}
115
+ </text>
116
+ {lw >= 28 && (
117
+ <text
118
+ x={CX - LABEL_W / 2 - lw + 4}
119
+ y={midY + 4}
120
+ fill="rgba(255,255,255,0.55)"
121
+ fontSize={9}
122
+ textAnchor="start"
123
+ fontFamily="inherit"
124
+ >
125
+ {item.left}
126
+ {unit}
127
+ </text>
128
+ )}
129
+ {rw >= 28 && (
130
+ <text
131
+ x={CX + LABEL_W / 2 + rw - 4}
132
+ y={midY + 4}
133
+ fill="rgba(255,255,255,0.55)"
134
+ fontSize={9}
135
+ textAnchor="end"
136
+ fontFamily="inherit"
137
+ >
138
+ {item.right}
139
+ {unit}
140
+ </text>
141
+ )}
142
+ </g>
143
+ )
144
+ })}
145
+ </svg>
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,87 @@
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 ModalProps {
6
+ open: boolean
7
+ title: string
8
+ onClose: () => void
9
+ children?: ReactNode
10
+ footer?: ReactNode
11
+ }
12
+
13
+ const FOCUSABLE =
14
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
15
+
16
+ export function Modal({ open, title, onClose, children, footer }: ModalProps) {
17
+ const titleId = useId()
18
+ const modalRef = useRef<HTMLDivElement>(null)
19
+ const downOnBackdropRef = useRef(false)
20
+ useEffect(() => {
21
+ if (!open) return
22
+ const previouslyFocused = document.activeElement as HTMLElement | null
23
+ const prevOverflow = document.body.style.overflow
24
+ document.body.style.overflow = 'hidden'
25
+ const node = modalRef.current
26
+ const first = node?.querySelector<HTMLElement>(FOCUSABLE)
27
+ ;(first ?? node)?.focus()
28
+ const onKey = (e: KeyboardEvent) => {
29
+ if (e.key === 'Escape') {
30
+ onClose()
31
+ return
32
+ }
33
+ if (e.key !== 'Tab' || !node) return
34
+ const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE))
35
+ if (items.length === 0) {
36
+ e.preventDefault()
37
+ return
38
+ }
39
+ const f = items[0]!
40
+ const l = items[items.length - 1]!
41
+ const active = document.activeElement
42
+ if (e.shiftKey && active === f) {
43
+ e.preventDefault()
44
+ l.focus()
45
+ } else if (!e.shiftKey && active === l) {
46
+ e.preventDefault()
47
+ f.focus()
48
+ }
49
+ }
50
+ window.addEventListener('keydown', onKey)
51
+ return () => {
52
+ window.removeEventListener('keydown', onKey)
53
+ document.body.style.overflow = prevOverflow
54
+ previouslyFocused?.focus?.()
55
+ }
56
+ }, [open, onClose])
57
+ return (
58
+ <div
59
+ className={`backdrop ${open ? 'show' : ''}`}
60
+ onMouseDown={(e) => {
61
+ downOnBackdropRef.current = e.target === e.currentTarget
62
+ }}
63
+ onClick={(e) => {
64
+ if (downOnBackdropRef.current && e.target === e.currentTarget) onClose()
65
+ downOnBackdropRef.current = false
66
+ }}
67
+ >
68
+ <div
69
+ ref={modalRef}
70
+ className="modal"
71
+ role="dialog"
72
+ aria-modal="true"
73
+ aria-labelledby={titleId}
74
+ tabIndex={-1}
75
+ >
76
+ <div className="modal-h">
77
+ <h2 id={titleId}>{title}</h2>
78
+ <IconButton onClick={onClose} aria-label="Close">
79
+ <CloseIcon />
80
+ </IconButton>
81
+ </div>
82
+ <div className="modal-b">{children}</div>
83
+ {footer && <div className="modal-f">{footer}</div>}
84
+ </div>
85
+ </div>
86
+ )
87
+ }
@@ -0,0 +1,232 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+ import { Tag } from './Tag.js'
4
+
5
+ export interface MultiSelectOption {
6
+ value: string
7
+ label: string
8
+ }
9
+
10
+ export interface MultiSelectProps {
11
+ options: MultiSelectOption[]
12
+ value?: string[]
13
+ onChange?: (values: string[]) => void
14
+ label?: string
15
+ placeholder?: string
16
+ id?: string
17
+ disabled?: boolean
18
+ className?: string
19
+ }
20
+
21
+ export function MultiSelect({
22
+ options,
23
+ value = [],
24
+ onChange,
25
+ label,
26
+ placeholder = 'Select…',
27
+ id,
28
+ disabled = false,
29
+ className = '',
30
+ }: MultiSelectProps) {
31
+ const generatedId = useId()
32
+ const inputId = id ?? generatedId
33
+ const listboxId = `${inputId}-lb`
34
+
35
+ const [open, setOpen] = useState(false)
36
+ const [query, setQuery] = useState('')
37
+ const [activeIdx, setActiveIdx] = useState(-1)
38
+
39
+ const wrapperRef = useRef<HTMLDivElement>(null)
40
+ const inputRef = useRef<HTMLInputElement>(null)
41
+
42
+ const selectedSet = new Set(value)
43
+
44
+ const filtered =
45
+ query.trim() === ''
46
+ ? options
47
+ : options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
48
+
49
+ const activeOptionId =
50
+ activeIdx >= 0 && filtered[activeIdx] ? `${listboxId}-opt-${activeIdx}` : undefined
51
+
52
+ useEffect(() => {
53
+ if (!open) return
54
+ function handler(e: MouseEvent) {
55
+ if (!wrapperRef.current?.contains(e.target as Node)) {
56
+ setOpen(false)
57
+ setQuery('')
58
+ }
59
+ }
60
+ document.addEventListener('mousedown', handler)
61
+ return () => document.removeEventListener('mousedown', handler)
62
+ }, [open])
63
+
64
+ function toggle(optValue: string) {
65
+ const next = new Set(selectedSet)
66
+ if (next.has(optValue)) next.delete(optValue)
67
+ else next.add(optValue)
68
+ onChange?.(Array.from(next))
69
+ }
70
+
71
+ function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
72
+ if (disabled) return
73
+ if (e.key === 'Escape') {
74
+ setOpen(false)
75
+ setQuery('')
76
+ return
77
+ }
78
+ if (e.key === 'Backspace' && query === '' && value.length > 0) {
79
+ onChange?.(value.slice(0, -1))
80
+ return
81
+ }
82
+ if (!open) {
83
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
84
+ e.preventDefault()
85
+ setOpen(true)
86
+ setActiveIdx(0)
87
+ }
88
+ return
89
+ }
90
+ if (e.key === 'ArrowDown') {
91
+ e.preventDefault()
92
+ setActiveIdx((i) => Math.min(i + 1, filtered.length - 1))
93
+ } else if (e.key === 'ArrowUp') {
94
+ e.preventDefault()
95
+ setActiveIdx((i) => Math.max(i - 1, 0))
96
+ } else if (e.key === 'Home') {
97
+ e.preventDefault()
98
+ setActiveIdx(0)
99
+ } else if (e.key === 'End') {
100
+ e.preventDefault()
101
+ setActiveIdx(filtered.length - 1)
102
+ } else if (e.key === 'Enter') {
103
+ e.preventDefault()
104
+ if (activeIdx >= 0 && filtered[activeIdx]) toggle(filtered[activeIdx].value)
105
+ } else if (e.key === 'Tab') {
106
+ setOpen(false)
107
+ setQuery('')
108
+ }
109
+ }
110
+
111
+ return (
112
+ <div ref={wrapperRef} className={`multiselect-wrapper ${className}`.trim()}>
113
+ {label && (
114
+ <label htmlFor={inputId} className="sr-only">
115
+ {label}
116
+ </label>
117
+ )}
118
+ <div
119
+ className={`multiselect-field${open ? ' multiselect-field--open' : ''}`}
120
+ onClick={() => {
121
+ if (!disabled) inputRef.current?.focus()
122
+ }}
123
+ >
124
+ {value.map((v) => {
125
+ const opt = options.find((o) => o.value === v)
126
+ return opt ? <Tag key={v} label={opt.label} onRemove={() => toggle(v)} /> : null
127
+ })}
128
+ <input
129
+ ref={inputRef}
130
+ id={inputId}
131
+ role="combobox"
132
+ aria-expanded={open}
133
+ aria-controls={listboxId}
134
+ aria-autocomplete="list"
135
+ aria-activedescendant={activeOptionId}
136
+ aria-label={label}
137
+ autoComplete="off"
138
+ spellCheck={false}
139
+ disabled={disabled}
140
+ placeholder={value.length === 0 ? placeholder : ''}
141
+ value={query}
142
+ onChange={(e) => {
143
+ setQuery(e.target.value)
144
+ setActiveIdx(-1)
145
+ if (!open) setOpen(true)
146
+ }}
147
+ onFocus={() => {
148
+ if (!disabled) setOpen(true)
149
+ }}
150
+ onKeyDown={onKeyDown}
151
+ className="multiselect-input"
152
+ />
153
+ <button
154
+ type="button"
155
+ tabIndex={-1}
156
+ aria-hidden="true"
157
+ className="combobox-chevron-btn"
158
+ onClick={(e) => {
159
+ e.stopPropagation()
160
+ if (open) {
161
+ setOpen(false)
162
+ setQuery('')
163
+ } else {
164
+ inputRef.current?.focus()
165
+ setOpen(true)
166
+ }
167
+ }}
168
+ >
169
+ <svg
170
+ className={`select-chevron${open ? ' combobox-chevron-open' : ''}`}
171
+ viewBox="0 0 16 16"
172
+ aria-hidden="true"
173
+ focusable={false}
174
+ >
175
+ <path
176
+ d="M4 6l4 4 4-4"
177
+ stroke="currentColor"
178
+ strokeWidth="1.5"
179
+ strokeLinecap="round"
180
+ strokeLinejoin="round"
181
+ fill="none"
182
+ />
183
+ </svg>
184
+ </button>
185
+ </div>
186
+ {open && (
187
+ <ul
188
+ id={listboxId}
189
+ role="listbox"
190
+ aria-label={label}
191
+ aria-multiselectable="true"
192
+ className="select-listbox multiselect-listbox"
193
+ >
194
+ {filtered.length === 0 ? (
195
+ <li className="combobox-empty">No results</li>
196
+ ) : (
197
+ filtered.map((opt, idx) => (
198
+ <li
199
+ key={opt.value}
200
+ id={`${listboxId}-opt-${idx}`}
201
+ role="option"
202
+ aria-selected={selectedSet.has(opt.value)}
203
+ data-active={idx === activeIdx ? 'true' : undefined}
204
+ className="select-option multiselect-option"
205
+ onMouseDown={(e) => {
206
+ e.preventDefault()
207
+ toggle(opt.value)
208
+ }}
209
+ onMouseEnter={() => setActiveIdx(idx)}
210
+ >
211
+ <span className="multiselect-check" aria-hidden="true">
212
+ {selectedSet.has(opt.value) && (
213
+ <svg viewBox="0 0 12 12" width="12" height="12" fill="none" aria-hidden="true">
214
+ <path
215
+ d="M2 6l3 3 5-5"
216
+ stroke="currentColor"
217
+ strokeWidth="1.5"
218
+ strokeLinecap="round"
219
+ strokeLinejoin="round"
220
+ />
221
+ </svg>
222
+ )}
223
+ </span>
224
+ {opt.label}
225
+ </li>
226
+ ))
227
+ )}
228
+ </ul>
229
+ )}
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1,101 @@
1
+ export interface NightingaleSegment {
2
+ label: string
3
+ value: number
4
+ color?: string
5
+ }
6
+
7
+ export interface NightingaleChartProps {
8
+ segments: NightingaleSegment[]
9
+ color?: string
10
+ ariaLabel?: string
11
+ }
12
+
13
+ const SIZE = 280
14
+ const CX = SIZE / 2
15
+ const CY = SIZE / 2
16
+ const MAX_R = 108
17
+ const MIN_R = 8
18
+
19
+ function wedgePath(cx: number, cy: number, r: number, sa: number, ea: number): string {
20
+ const x1 = cx + r * Math.cos(sa)
21
+ const y1 = cy + r * Math.sin(sa)
22
+ const x2 = cx + r * Math.cos(ea)
23
+ const y2 = cy + r * Math.sin(ea)
24
+ return `M ${cx} ${cy} L ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 0 1 ${x2.toFixed(2)} ${y2.toFixed(2)} Z`
25
+ }
26
+
27
+ export function NightingaleChart({
28
+ segments,
29
+ color = '#006FFF',
30
+ ariaLabel = 'Nightingale chart',
31
+ }: NightingaleChartProps) {
32
+ if (!segments.length) return null
33
+ const n = segments.length
34
+ const maxVal = Math.max(...segments.map((s) => s.value), 1)
35
+ const step = (2 * Math.PI) / n
36
+ const BASE = -Math.PI / 2
37
+ const LABEL_R = MAX_R + 16
38
+ const labelEvery = n <= 8 ? 1 : Math.round(n / 8)
39
+
40
+ return (
41
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
42
+ <svg
43
+ viewBox={`0 0 ${SIZE} ${SIZE}`}
44
+ style={{ width: '100%', height: 'auto', display: 'block' }}
45
+ aria-hidden="true"
46
+ focusable="false"
47
+ >
48
+ {[0.25, 0.5, 0.75, 1].map((f) => (
49
+ <circle
50
+ key={f}
51
+ cx={CX}
52
+ cy={CY}
53
+ r={MIN_R + (MAX_R - MIN_R) * f}
54
+ fill="none"
55
+ stroke="rgba(255,255,255,0.06)"
56
+ strokeWidth={1}
57
+ />
58
+ ))}
59
+ {segments.map((seg, i) => {
60
+ const sa = BASE + i * step
61
+ const ea = BASE + (i + 1) * step
62
+ const r = MIN_R + (seg.value / maxVal) * (MAX_R - MIN_R)
63
+ const c = seg.color ?? color
64
+ const mid = sa + step / 2
65
+ const lx = CX + LABEL_R * Math.cos(mid)
66
+ const ly = CY + LABEL_R * Math.sin(mid)
67
+ return (
68
+ <g key={i}>
69
+ <path
70
+ d={wedgePath(CX, CY, r, sa, ea)}
71
+ fill={c}
72
+ opacity={0.3 + 0.7 * (seg.value / maxVal)}
73
+ />
74
+ {i % labelEvery === 0 && (
75
+ <text
76
+ x={lx.toFixed(1)}
77
+ y={ly.toFixed(1)}
78
+ fill="#6E7079"
79
+ fontSize={8}
80
+ textAnchor="middle"
81
+ dominantBaseline="middle"
82
+ fontFamily="inherit"
83
+ >
84
+ {seg.label}
85
+ </text>
86
+ )}
87
+ </g>
88
+ )
89
+ })}
90
+ <circle
91
+ cx={CX}
92
+ cy={CY}
93
+ r={MIN_R}
94
+ fill="#13131A"
95
+ stroke="rgba(255,255,255,0.1)"
96
+ strokeWidth={1}
97
+ />
98
+ </svg>
99
+ </div>
100
+ )
101
+ }