@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,78 @@
1
+ import { useState, useRef } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+
4
+ export interface SegmentOption {
5
+ value: string
6
+ label: string
7
+ disabled?: boolean
8
+ }
9
+
10
+ export interface SegmentedControlProps {
11
+ label: string
12
+ options: SegmentOption[]
13
+ value?: string
14
+ defaultValue?: string
15
+ onChange?: (value: string) => void
16
+ }
17
+
18
+ export function SegmentedControl({
19
+ label,
20
+ options,
21
+ value: controlledValue,
22
+ defaultValue,
23
+ onChange,
24
+ }: SegmentedControlProps) {
25
+ const [internalValue, setInternalValue] = useState(defaultValue ?? options[0]?.value ?? '')
26
+ const active = controlledValue !== undefined ? controlledValue : internalValue
27
+ const rootRef = useRef<HTMLDivElement>(null)
28
+
29
+ function select(val: string) {
30
+ if (controlledValue === undefined) setInternalValue(val)
31
+ onChange?.(val)
32
+ }
33
+
34
+ function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
35
+ const enabled = options.filter((o) => !o.disabled)
36
+ const idx = enabled.findIndex((o) => o.value === active)
37
+ let next = -1
38
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
39
+ e.preventDefault()
40
+ next = (idx + 1) % enabled.length
41
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
42
+ e.preventDefault()
43
+ next = (idx - 1 + enabled.length) % enabled.length
44
+ }
45
+ if (next !== -1) {
46
+ select(enabled[next].value)
47
+ const btns = rootRef.current?.querySelectorAll<HTMLButtonElement>('button[role="radio"]')
48
+ btns?.[next]?.focus()
49
+ }
50
+ }
51
+
52
+ return (
53
+ <div
54
+ ref={rootRef}
55
+ role="radiogroup"
56
+ aria-label={label}
57
+ className="seg-ctrl"
58
+ onKeyDown={handleKeyDown}
59
+ >
60
+ {options.map((opt) => (
61
+ <button
62
+ key={opt.value}
63
+ type="button"
64
+ role="radio"
65
+ aria-checked={active === opt.value}
66
+ disabled={opt.disabled}
67
+ tabIndex={active === opt.value ? 0 : -1}
68
+ className={['seg-ctrl__btn', active === opt.value && 'seg-ctrl__btn--active']
69
+ .filter(Boolean)
70
+ .join(' ')}
71
+ onClick={() => select(opt.value)}
72
+ >
73
+ {opt.label}
74
+ </button>
75
+ ))}
76
+ </div>
77
+ )
78
+ }
@@ -0,0 +1,158 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+
4
+ export interface SelectOption {
5
+ value: string
6
+ label: string
7
+ }
8
+
9
+ export interface SelectProps {
10
+ options: SelectOption[]
11
+ value?: string
12
+ onChange?: (value: string) => void
13
+ label?: string
14
+ placeholder?: string
15
+ id?: string
16
+ disabled?: boolean
17
+ className?: string
18
+ }
19
+
20
+ export function Select({
21
+ options,
22
+ value,
23
+ onChange,
24
+ label,
25
+ placeholder = 'Select…',
26
+ id,
27
+ disabled = false,
28
+ className = '',
29
+ }: SelectProps) {
30
+ const generatedId = useId()
31
+ const triggerId = id ?? generatedId
32
+ const listboxId = `${triggerId}-lb`
33
+ const [open, setOpen] = useState(false)
34
+ const [activeIdx, setActiveIdx] = useState(-1)
35
+ const triggerRef = useRef<HTMLButtonElement>(null)
36
+ const wrapperRef = useRef<HTMLDivElement>(null)
37
+
38
+ const selected = options.find((o) => o.value === value)
39
+ const displayLabel = selected?.label ?? placeholder
40
+
41
+ useEffect(() => {
42
+ if (!open) return
43
+ function handler(e: MouseEvent) {
44
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false)
45
+ }
46
+ document.addEventListener('mousedown', handler)
47
+ return () => document.removeEventListener('mousedown', handler)
48
+ }, [open])
49
+
50
+ function toggle() {
51
+ if (disabled) return
52
+ setOpen((prev) => {
53
+ if (!prev) {
54
+ const idx = options.findIndex((o) => o.value === value)
55
+ setActiveIdx(idx >= 0 ? idx : 0)
56
+ }
57
+ return !prev
58
+ })
59
+ }
60
+
61
+ function pick(val: string) {
62
+ onChange?.(val)
63
+ setOpen(false)
64
+ triggerRef.current?.focus()
65
+ }
66
+
67
+ function onKeyDown(e: KeyboardEvent) {
68
+ if (disabled) return
69
+ if (e.key === 'Escape') {
70
+ setOpen(false)
71
+ triggerRef.current?.focus()
72
+ return
73
+ }
74
+ if (!open) {
75
+ if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(e.key)) {
76
+ e.preventDefault()
77
+ const idx = options.findIndex((o) => o.value === value)
78
+ setActiveIdx(idx >= 0 ? idx : 0)
79
+ setOpen(true)
80
+ }
81
+ return
82
+ }
83
+ if (e.key === 'ArrowDown') {
84
+ e.preventDefault()
85
+ setActiveIdx((i) => Math.min(i + 1, options.length - 1))
86
+ } else if (e.key === 'ArrowUp') {
87
+ e.preventDefault()
88
+ setActiveIdx((i) => Math.max(i - 1, 0))
89
+ } else if (e.key === 'Home') {
90
+ e.preventDefault()
91
+ setActiveIdx(0)
92
+ } else if (e.key === 'End') {
93
+ e.preventDefault()
94
+ setActiveIdx(options.length - 1)
95
+ } else if (e.key === 'Enter' || e.key === ' ') {
96
+ e.preventDefault()
97
+ if (activeIdx >= 0) pick(options[activeIdx].value)
98
+ } else if (e.key === 'Tab') {
99
+ setOpen(false)
100
+ }
101
+ }
102
+
103
+ return (
104
+ <div ref={wrapperRef} className={`select-wrapper ${className}`.trim()}>
105
+ {label && (
106
+ <label htmlFor={triggerId} className="sr-only">
107
+ {label}
108
+ </label>
109
+ )}
110
+ <button
111
+ ref={triggerRef}
112
+ id={triggerId}
113
+ role="combobox"
114
+ aria-haspopup="listbox"
115
+ aria-expanded={open}
116
+ aria-controls={listboxId}
117
+ aria-label={label}
118
+ disabled={disabled}
119
+ className="select-trigger"
120
+ onClick={toggle}
121
+ onKeyDown={onKeyDown}
122
+ type="button"
123
+ >
124
+ <span>{displayLabel}</span>
125
+ <svg className="select-chevron" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
126
+ <path
127
+ d="M4 6l4 4 4-4"
128
+ stroke="currentColor"
129
+ strokeWidth="1.5"
130
+ strokeLinecap="round"
131
+ strokeLinejoin="round"
132
+ fill="none"
133
+ />
134
+ </svg>
135
+ </button>
136
+ {open && (
137
+ <ul id={listboxId} role="listbox" aria-label={label} className="select-listbox">
138
+ {options.map((opt, idx) => (
139
+ <li
140
+ key={opt.value}
141
+ role="option"
142
+ aria-selected={opt.value === value}
143
+ data-active={idx === activeIdx ? 'true' : undefined}
144
+ className="select-option"
145
+ onMouseDown={(e) => {
146
+ e.preventDefault()
147
+ pick(opt.value)
148
+ }}
149
+ onMouseEnter={() => setActiveIdx(idx)}
150
+ >
151
+ {opt.label}
152
+ </li>
153
+ ))}
154
+ </ul>
155
+ )}
156
+ </div>
157
+ )
158
+ }
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from 'react'
2
+ import { CloseIcon } from '../icons.js'
3
+
4
+ export interface SelectionToolbarAction {
5
+ label: string
6
+ onClick: () => void
7
+ variant?: 'default' | 'danger'
8
+ }
9
+
10
+ export interface SelectionToolbarProps {
11
+ count: number
12
+ actions: SelectionToolbarAction[]
13
+ onClear: () => void
14
+ /** sr-only label for the toolbar (default: "Selection actions") */
15
+ ariaLabel?: string
16
+ children?: ReactNode
17
+ }
18
+
19
+ export function SelectionToolbar({
20
+ count,
21
+ actions,
22
+ onClear,
23
+ ariaLabel = 'Selection actions',
24
+ children,
25
+ }: SelectionToolbarProps) {
26
+ if (count === 0) return null
27
+ return (
28
+ <div className="sel-toolbar" role="toolbar" aria-label={ariaLabel}>
29
+ <span className="sel-toolbar__count" aria-live="polite" aria-atomic="true">
30
+ <strong>{count}</strong> selected
31
+ </span>
32
+ <div className="sel-toolbar__actions">
33
+ {actions.map((a) => (
34
+ <button
35
+ key={a.label}
36
+ type="button"
37
+ className={`sel-toolbar__btn${a.variant === 'danger' ? ' sel-toolbar__btn--danger' : ''}`}
38
+ onClick={a.onClick}
39
+ >
40
+ {a.label}
41
+ </button>
42
+ ))}
43
+ {children}
44
+ </div>
45
+ <button
46
+ type="button"
47
+ className="sel-toolbar__clear icon-btn"
48
+ onClick={onClear}
49
+ aria-label="Clear selection"
50
+ >
51
+ <CloseIcon aria-hidden="true" />
52
+ </button>
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,19 @@
1
+ export interface SignalProps {
2
+ weak?: boolean
3
+ label?: string
4
+ }
5
+
6
+ export function Signal({ weak = false, label }: SignalProps) {
7
+ return (
8
+ <span
9
+ className={`signal ${weak ? 'weak' : ''}`.trim()}
10
+ role="img"
11
+ aria-label={label ?? (weak ? 'Signal: weak' : 'Signal: strong')}
12
+ >
13
+ <i />
14
+ <i />
15
+ <i />
16
+ <i />
17
+ </span>
18
+ )
19
+ }
@@ -0,0 +1,37 @@
1
+ import type { CSSProperties } from 'react'
2
+
3
+ export type SkeletonVariant = 'text' | 'title' | 'stat' | 'circle' | 'block'
4
+
5
+ export interface SkeletonProps {
6
+ variant?: SkeletonVariant
7
+ width?: number | string
8
+ height?: number | string
9
+ className?: string
10
+ style?: CSSProperties
11
+ ariaLabel?: string
12
+ }
13
+
14
+ export function Skeleton({
15
+ variant = 'text',
16
+ width,
17
+ height,
18
+ className = '',
19
+ style,
20
+ ariaLabel = 'Loading',
21
+ }: SkeletonProps) {
22
+ const variantClass = variant === 'block' ? '' : variant
23
+ const mergedStyle: CSSProperties = {
24
+ width: width ?? (variant === 'circle' ? height : undefined),
25
+ height,
26
+ ...style,
27
+ }
28
+ return (
29
+ <span
30
+ className={`skeleton ${variantClass} ${className}`.trim()}
31
+ style={mergedStyle}
32
+ role="status"
33
+ aria-label={ariaLabel}
34
+ aria-busy="true"
35
+ />
36
+ )
37
+ }
@@ -0,0 +1,19 @@
1
+ import type { AnchorHTMLAttributes } from 'react'
2
+
3
+ export interface SkipLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
4
+ href?: string
5
+ children?: React.ReactNode
6
+ }
7
+
8
+ export function SkipLink({
9
+ href = '#main-content',
10
+ children = 'Skip to main content',
11
+ className = '',
12
+ ...rest
13
+ }: SkipLinkProps) {
14
+ return (
15
+ <a className={`skip-link ${className}`.trim()} href={href} {...rest}>
16
+ {children}
17
+ </a>
18
+ )
19
+ }
@@ -0,0 +1,74 @@
1
+ import { useState, useId } from 'react'
2
+ import type { ChangeEvent, CSSProperties } from 'react'
3
+
4
+ export interface SliderProps {
5
+ label?: string
6
+ value?: number
7
+ defaultValue?: number
8
+ min?: number
9
+ max?: number
10
+ step?: number
11
+ suffix?: string
12
+ disabled?: boolean
13
+ onChange?: (value: number) => void
14
+ id?: string
15
+ className?: string
16
+ }
17
+
18
+ export function Slider({
19
+ label,
20
+ value: valueProp,
21
+ defaultValue = 0,
22
+ min = 0,
23
+ max = 100,
24
+ step = 1,
25
+ suffix,
26
+ disabled = false,
27
+ onChange,
28
+ id,
29
+ className = '',
30
+ }: SliderProps) {
31
+ const generatedId = useId()
32
+ const inputId = id ?? generatedId
33
+ const [internalValue, setInternalValue] = useState(defaultValue)
34
+ const value = valueProp !== undefined ? valueProp : internalValue
35
+
36
+ const pct = max === min ? 0 : ((value - min) / (max - min)) * 100
37
+ const fillStyle: CSSProperties = { '--slider-fill': `${pct}%` } as CSSProperties
38
+
39
+ function handleChange(e: ChangeEvent<HTMLInputElement>) {
40
+ const next = Number(e.target.value)
41
+ if (valueProp === undefined) setInternalValue(next)
42
+ onChange?.(next)
43
+ }
44
+
45
+ return (
46
+ <div className={`slider ${className}`.trim()}>
47
+ {label && (
48
+ <div className="slider__header">
49
+ <label htmlFor={inputId} className="slider__label">
50
+ {label}
51
+ </label>
52
+ <span className="slider__value" aria-live="polite">
53
+ {value}
54
+ {suffix && <span className="slider__suffix">{suffix}</span>}
55
+ </span>
56
+ </div>
57
+ )}
58
+ <input
59
+ id={inputId}
60
+ type="range"
61
+ className="slider__track"
62
+ min={min}
63
+ max={max}
64
+ step={step}
65
+ value={value}
66
+ disabled={disabled}
67
+ style={fillStyle}
68
+ onChange={handleChange}
69
+ aria-label={label}
70
+ aria-valuetext={suffix ? `${value} ${suffix}` : undefined}
71
+ />
72
+ </div>
73
+ )
74
+ }
@@ -0,0 +1,205 @@
1
+ export interface SlopeItem {
2
+ label: string
3
+ before: number
4
+ after: number
5
+ }
6
+
7
+ export interface SlopeChartProps {
8
+ items: SlopeItem[]
9
+ labelBefore?: string
10
+ labelAfter?: string
11
+ unit?: string
12
+ ariaLabel?: string
13
+ positiveIsGood?: boolean
14
+ }
15
+
16
+ const VW = 340
17
+ const COL_L = 108
18
+ const COL_R = 232
19
+ const PAD_T = 26
20
+ const CHART_H = 192
21
+ const PAD_B = 8
22
+ const SVG_H = PAD_T + CHART_H + PAD_B
23
+ const DOT_R = 4
24
+ const LABEL_GAP = 15
25
+
26
+ function nudge(ys: number[], gap: number): number[] {
27
+ const out = [...ys]
28
+ const ord = out.map((y, i) => ({ y, i })).sort((a, b) => a.y - b.y)
29
+ for (let k = 1; k < ord.length; k++) {
30
+ if (ord[k].y < ord[k - 1].y + gap) ord[k].y = ord[k - 1].y + gap
31
+ }
32
+ for (let k = ord.length - 2; k >= 0; k--) {
33
+ if (ord[k].y > ord[k + 1].y - gap) ord[k].y = ord[k + 1].y - gap
34
+ }
35
+ const res: number[] = Array.from({ length: ys.length })
36
+ ord.forEach(({ y, i }) => {
37
+ res[i] = y
38
+ })
39
+ return res
40
+ }
41
+
42
+ export function SlopeChart({
43
+ items,
44
+ labelBefore = 'Before',
45
+ labelAfter = 'After',
46
+ unit = '',
47
+ ariaLabel = 'Slope chart',
48
+ positiveIsGood = false,
49
+ }: SlopeChartProps) {
50
+ if (!items.length) return null
51
+
52
+ const allVals = items.flatMap((it) => [it.before, it.after])
53
+ const minV = Math.min(...allVals)
54
+ const maxV = Math.max(...allVals)
55
+ const range = maxV - minV || 1
56
+ const ty = (v: number) => PAD_T + (1 - (v - minV) / range) * CHART_H
57
+
58
+ const leftDotY = items.map((it) => ty(it.before))
59
+ const rightDotY = items.map((it) => ty(it.after))
60
+ const leftLabelY = nudge(leftDotY, LABEL_GAP)
61
+ const rightLabelY = nudge(rightDotY, LABEL_GAP)
62
+
63
+ const itemsWithColor = items.map((it) => {
64
+ const diff = it.after - it.before
65
+ const c = diff === 0 ? '#A4A7B5' : diff > 0 === positiveIsGood ? '#00C875' : '#FF7B7B'
66
+ return { ...it, c }
67
+ })
68
+
69
+ return (
70
+ <div role="img" aria-label={ariaLabel} style={{ width: '100%' }}>
71
+ <svg
72
+ viewBox={`0 0 ${VW} ${SVG_H}`}
73
+ style={{ width: '100%', height: 'auto', display: 'block' }}
74
+ aria-hidden="true"
75
+ focusable="false"
76
+ >
77
+ <text
78
+ x={COL_L}
79
+ y={14}
80
+ fill="#6E7079"
81
+ fontSize={10}
82
+ textAnchor="middle"
83
+ fontFamily="inherit"
84
+ >
85
+ {labelBefore}
86
+ </text>
87
+ <text
88
+ x={COL_R}
89
+ y={14}
90
+ fill="#6E7079"
91
+ fontSize={10}
92
+ textAnchor="middle"
93
+ fontFamily="inherit"
94
+ >
95
+ {labelAfter}
96
+ </text>
97
+ <line
98
+ x1={COL_L}
99
+ y1={PAD_T}
100
+ x2={COL_L}
101
+ y2={PAD_T + CHART_H}
102
+ stroke="rgba(255,255,255,0.07)"
103
+ strokeWidth={1}
104
+ />
105
+ <line
106
+ x1={COL_R}
107
+ y1={PAD_T}
108
+ x2={COL_R}
109
+ y2={PAD_T + CHART_H}
110
+ stroke="rgba(255,255,255,0.07)"
111
+ strokeWidth={1}
112
+ />
113
+
114
+ {itemsWithColor.map((it, i) => {
115
+ const ly = leftDotY[i]
116
+ const ry = rightDotY[i]
117
+ const lly = leftLabelY[i]
118
+ const rly = rightLabelY[i]
119
+ return (
120
+ <g key={i}>
121
+ <line
122
+ x1={COL_L}
123
+ y1={ly}
124
+ x2={COL_R}
125
+ y2={ry}
126
+ stroke={it.c}
127
+ strokeWidth={1.5}
128
+ opacity={0.65}
129
+ />
130
+ {Math.abs(lly - ly) > 1 && (
131
+ <line
132
+ x1={COL_L - 6}
133
+ y1={ly}
134
+ x2={COL_L - 6}
135
+ y2={lly}
136
+ stroke="rgba(255,255,255,0.12)"
137
+ strokeWidth={0.75}
138
+ />
139
+ )}
140
+ {Math.abs(rly - ry) > 1 && (
141
+ <line
142
+ x1={COL_R + 6}
143
+ y1={ry}
144
+ x2={COL_R + 6}
145
+ y2={rly}
146
+ stroke="rgba(255,255,255,0.12)"
147
+ strokeWidth={0.75}
148
+ />
149
+ )}
150
+ <circle cx={COL_L} cy={ly} r={DOT_R} fill={it.c} />
151
+ <circle cx={COL_R} cy={ry} r={DOT_R} fill={it.c} />
152
+ <text
153
+ x={COL_L - 10}
154
+ y={lly + 4}
155
+ fill="#A4A7B5"
156
+ fontSize={9}
157
+ textAnchor="end"
158
+ fontFamily="inherit"
159
+ >
160
+ {it.label}
161
+ </text>
162
+ <text
163
+ x={COL_L - 10}
164
+ y={lly + 15}
165
+ fill="#6E7079"
166
+ fontSize={9}
167
+ textAnchor="end"
168
+ fontFamily="inherit"
169
+ style={{ fontVariantNumeric: 'tabular-nums' }}
170
+ >
171
+ {it.before}
172
+ {unit}
173
+ </text>
174
+ <text
175
+ x={COL_R + 10}
176
+ y={rly + 4}
177
+ fill="#A4A7B5"
178
+ fontSize={9}
179
+ textAnchor="start"
180
+ fontFamily="inherit"
181
+ style={{ fontVariantNumeric: 'tabular-nums' }}
182
+ >
183
+ {it.after}
184
+ {unit}
185
+ </text>
186
+ <text
187
+ x={COL_R + 10}
188
+ y={rly + 15}
189
+ fill={it.c}
190
+ fontSize={9}
191
+ textAnchor="start"
192
+ fontFamily="inherit"
193
+ style={{ fontVariantNumeric: 'tabular-nums' }}
194
+ >
195
+ {it.after > it.before ? '+' : ''}
196
+ {it.after - it.before}
197
+ {unit}
198
+ </text>
199
+ </g>
200
+ )
201
+ })}
202
+ </svg>
203
+ </div>
204
+ )
205
+ }