@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,152 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+
4
+ const PRESETS = [
5
+ { id: '1h', label: 'Last 1 hour', short: 'Last 1 h' },
6
+ { id: '6h', label: 'Last 6 hours', short: 'Last 6 h' },
7
+ { id: '24h', label: 'Last 24 hours', short: 'Last 24 h' },
8
+ { id: '7d', label: 'Last 7 days', short: 'Last 7 d' },
9
+ { id: '30d', label: 'Last 30 days', short: 'Last 30 d' },
10
+ ] as const
11
+
12
+ export type TimeRangeId = (typeof PRESETS)[number]['id']
13
+
14
+ export interface TimeRangeProps {
15
+ value?: TimeRangeId
16
+ onChange?: (value: TimeRangeId) => void
17
+ }
18
+
19
+ export function TimeRange({ value = '1h', onChange }: TimeRangeProps) {
20
+ const uid = useId()
21
+ const listId = `${uid}-list`
22
+ const [open, setOpen] = useState(false)
23
+ const [activeIdx, setActiveIdx] = useState(0)
24
+ const triggerRef = useRef<HTMLButtonElement>(null)
25
+ const wrapperRef = useRef<HTMLDivElement>(null)
26
+
27
+ const selected = PRESETS.find((p) => p.id === value) ?? PRESETS[0]
28
+
29
+ useEffect(() => {
30
+ if (!open) return
31
+ function handler(e: MouseEvent) {
32
+ if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false)
33
+ }
34
+ document.addEventListener('mousedown', handler)
35
+ return () => document.removeEventListener('mousedown', handler)
36
+ }, [open])
37
+
38
+ function toggle() {
39
+ setOpen((prev) => {
40
+ if (!prev) setActiveIdx(PRESETS.findIndex((p) => p.id === value))
41
+ return !prev
42
+ })
43
+ }
44
+
45
+ function select(id: TimeRangeId) {
46
+ onChange?.(id)
47
+ setOpen(false)
48
+ triggerRef.current?.focus()
49
+ }
50
+
51
+ function onKeyDown(e: KeyboardEvent) {
52
+ if (!open) {
53
+ if (['ArrowDown', 'Enter', ' '].includes(e.key)) {
54
+ e.preventDefault()
55
+ setActiveIdx(PRESETS.findIndex((p) => p.id === value))
56
+ setOpen(true)
57
+ }
58
+ return
59
+ }
60
+ if (e.key === 'Escape') {
61
+ e.preventDefault()
62
+ setOpen(false)
63
+ triggerRef.current?.focus()
64
+ } else if (e.key === 'ArrowDown') {
65
+ e.preventDefault()
66
+ setActiveIdx((i) => Math.min(i + 1, PRESETS.length - 1))
67
+ } else if (e.key === 'ArrowUp') {
68
+ e.preventDefault()
69
+ setActiveIdx((i) => Math.max(i - 1, 0))
70
+ } else if (e.key === 'Home') {
71
+ e.preventDefault()
72
+ setActiveIdx(0)
73
+ } else if (e.key === 'End') {
74
+ e.preventDefault()
75
+ setActiveIdx(PRESETS.length - 1)
76
+ } else if (e.key === 'Enter' || e.key === ' ') {
77
+ e.preventDefault()
78
+ select(PRESETS[activeIdx].id)
79
+ } else if (e.key === 'Tab') {
80
+ setOpen(false)
81
+ }
82
+ }
83
+
84
+ return (
85
+ <div ref={wrapperRef} className="time-range-root">
86
+ <button
87
+ ref={triggerRef}
88
+ type="button"
89
+ className="btn time-range-trigger"
90
+ aria-haspopup="listbox"
91
+ aria-expanded={open}
92
+ aria-controls={open ? listId : undefined}
93
+ onClick={toggle}
94
+ onKeyDown={onKeyDown}
95
+ >
96
+ {selected.short}
97
+ <svg
98
+ className="time-range-chevron"
99
+ width="10"
100
+ height="6"
101
+ viewBox="0 0 10 6"
102
+ aria-hidden="true"
103
+ focusable="false"
104
+ fill="none"
105
+ stroke="currentColor"
106
+ strokeWidth="1.5"
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ >
110
+ <path d="M1 1l4 4 4-4" />
111
+ </svg>
112
+ </button>
113
+ {open && (
114
+ <ul id={listId} role="listbox" aria-label="Time range" className="time-range-list">
115
+ {PRESETS.map((p, idx) => (
116
+ <li
117
+ key={p.id}
118
+ role="option"
119
+ aria-selected={p.id === value}
120
+ tabIndex={-1}
121
+ data-active={idx === activeIdx ? 'true' : undefined}
122
+ className="time-range-option"
123
+ onMouseEnter={() => setActiveIdx(idx)}
124
+ onMouseDown={(e) => {
125
+ e.preventDefault()
126
+ select(p.id)
127
+ }}
128
+ >
129
+ {p.label}
130
+ {p.id === value && (
131
+ <svg
132
+ width="12"
133
+ height="12"
134
+ viewBox="0 0 12 12"
135
+ aria-hidden="true"
136
+ focusable="false"
137
+ fill="none"
138
+ stroke="currentColor"
139
+ strokeWidth="1.5"
140
+ strokeLinecap="round"
141
+ strokeLinejoin="round"
142
+ >
143
+ <path d="M2 6l3 3 5-5" />
144
+ </svg>
145
+ )}
146
+ </li>
147
+ ))}
148
+ </ul>
149
+ )}
150
+ </div>
151
+ )
152
+ }
@@ -0,0 +1,31 @@
1
+ export interface TimelineEvent {
2
+ id: string
3
+ title: string
4
+ description?: string
5
+ time: string
6
+ variant?: 'info' | 'warn' | 'danger' | 'success' | 'neutral'
7
+ }
8
+
9
+ export interface TimelineProps {
10
+ events: TimelineEvent[]
11
+ className?: string
12
+ }
13
+
14
+ export function Timeline({ events, className = '' }: TimelineProps) {
15
+ return (
16
+ <ol className={`timeline ${className}`.trim()} aria-label="Event timeline">
17
+ {events.map((e) => (
18
+ <li key={e.id} className={`timeline__item timeline__item--${e.variant ?? 'neutral'}`}>
19
+ <span className="timeline__dot" aria-hidden="true" />
20
+ <div className="timeline__content">
21
+ <div className="timeline__header">
22
+ <span className="timeline__title">{e.title}</span>
23
+ <time className="timeline__time">{e.time}</time>
24
+ </div>
25
+ {e.description && <p className="timeline__desc">{e.description}</p>}
26
+ </div>
27
+ </li>
28
+ ))}
29
+ </ol>
30
+ )
31
+ }
@@ -0,0 +1,72 @@
1
+ import { useState, useEffect } from 'react'
2
+ import { CloseIcon } from '../icons.js'
3
+
4
+ export type ToastVariant = 'success' | 'info' | 'warn' | 'danger'
5
+
6
+ interface ToastItem {
7
+ id: string
8
+ message: string
9
+ variant: ToastVariant
10
+ }
11
+
12
+ type Listener = (item: ToastItem) => void
13
+ const listeners = new Set<Listener>()
14
+ let seq = 0
15
+
16
+ export const toast = {
17
+ show(message: string, variant: ToastVariant = 'info') {
18
+ const item: ToastItem = { id: String(++seq), message, variant }
19
+ listeners.forEach((l) => l(item))
20
+ },
21
+ success: (msg: string) => toast.show(msg, 'success'),
22
+ warn: (msg: string) => toast.show(msg, 'warn'),
23
+ danger: (msg: string) => toast.show(msg, 'danger'),
24
+ info: (msg: string) => toast.show(msg, 'info'),
25
+ }
26
+
27
+ export function Toaster() {
28
+ const [items, setItems] = useState<ToastItem[]>([])
29
+
30
+ useEffect(() => {
31
+ const add = (item: ToastItem) => {
32
+ setItems((prev) => [...prev, item])
33
+ setTimeout(() => remove(item.id), 4000)
34
+ }
35
+ listeners.add(add)
36
+ return () => {
37
+ listeners.delete(add)
38
+ }
39
+ }, [])
40
+
41
+ function remove(id: string) {
42
+ setItems((prev) => prev.filter((t) => t.id !== id))
43
+ }
44
+
45
+ return (
46
+ <div
47
+ className="toaster"
48
+ role="log"
49
+ aria-label="Notifications"
50
+ aria-live="polite"
51
+ aria-atomic="false"
52
+ >
53
+ {items.map((item) => (
54
+ <div
55
+ key={item.id}
56
+ className={`toast toast-${item.variant}`}
57
+ role={item.variant === 'danger' ? 'alert' : 'status'}
58
+ >
59
+ <span className="toast-msg">{item.message}</span>
60
+ <button
61
+ type="button"
62
+ className="toast-dismiss icon-btn"
63
+ onClick={() => remove(item.id)}
64
+ aria-label="Dismiss notification"
65
+ >
66
+ <CloseIcon aria-hidden="true" />
67
+ </button>
68
+ </div>
69
+ ))}
70
+ </div>
71
+ )
72
+ }
@@ -0,0 +1,68 @@
1
+ import { useId } from 'react'
2
+
3
+ export interface ToggleProps {
4
+ on: boolean
5
+ onToggle?: () => void
6
+ ariaLabel?: string
7
+ ariaDescribedBy?: string
8
+ }
9
+
10
+ export function Toggle({ on, onToggle, ariaLabel, ariaDescribedBy }: ToggleProps) {
11
+ return (
12
+ <button
13
+ type="button"
14
+ role="switch"
15
+ aria-checked={on}
16
+ aria-label={ariaLabel}
17
+ aria-describedby={ariaDescribedBy}
18
+ onClick={onToggle}
19
+ className={`toggle-track ${on ? 'on' : 'off'}`}
20
+ >
21
+ <span className="knob" />
22
+ </button>
23
+ )
24
+ }
25
+
26
+ export interface RowToggleProps {
27
+ title: string
28
+ description: string
29
+ on: boolean
30
+ onToggle?: () => void
31
+ }
32
+
33
+ export function RowToggle({ title, description, on, onToggle }: RowToggleProps) {
34
+ const descId = useId()
35
+ return (
36
+ <div className="row-toggle">
37
+ <div className="info">
38
+ <div className="t">{title}</div>
39
+ <div className="d" id={descId}>
40
+ {description}
41
+ </div>
42
+ </div>
43
+ <Toggle on={on} onToggle={onToggle} ariaLabel={title} ariaDescribedBy={descId} />
44
+ </div>
45
+ )
46
+ }
47
+
48
+ export interface RowToggleListProps {
49
+ items: { title: string; description: string }[]
50
+ state: Record<string, boolean>
51
+ onToggle: (title: string) => void
52
+ }
53
+
54
+ export function RowToggleList({ items, state, onToggle }: RowToggleListProps) {
55
+ return (
56
+ <>
57
+ {items.map((it) => (
58
+ <RowToggle
59
+ key={it.title}
60
+ title={it.title}
61
+ description={it.description}
62
+ on={!!state[it.title]}
63
+ onToggle={() => onToggle(it.title)}
64
+ />
65
+ ))}
66
+ </>
67
+ )
68
+ }
@@ -0,0 +1,57 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export interface ToggleGroupOption {
4
+ value: string
5
+ label: ReactNode
6
+ disabled?: boolean
7
+ }
8
+
9
+ export interface ToggleGroupProps {
10
+ options: ToggleGroupOption[]
11
+ value: string[]
12
+ onChange: (value: string[]) => void
13
+ ariaLabel: string
14
+ size?: 'sm' | 'md'
15
+ className?: string
16
+ }
17
+
18
+ export function ToggleGroup({
19
+ options,
20
+ value,
21
+ onChange,
22
+ ariaLabel,
23
+ size = 'md',
24
+ className = '',
25
+ }: ToggleGroupProps) {
26
+ function toggle(v: string) {
27
+ if (value.includes(v)) {
28
+ onChange(value.filter((x) => x !== v))
29
+ } else {
30
+ onChange([...value, v])
31
+ }
32
+ }
33
+
34
+ return (
35
+ <div
36
+ role="group"
37
+ aria-label={ariaLabel}
38
+ className={`tg-group tg-group--${size}${className ? ` ${className}` : ''}`}
39
+ >
40
+ {options.map((opt) => {
41
+ const pressed = value.includes(opt.value)
42
+ return (
43
+ <button
44
+ key={opt.value}
45
+ type="button"
46
+ aria-pressed={pressed}
47
+ disabled={opt.disabled}
48
+ className={`tg-btn${pressed ? ' tg-btn--on' : ''}`}
49
+ onClick={() => toggle(opt.value)}
50
+ >
51
+ {opt.label}
52
+ </button>
53
+ )
54
+ })}
55
+ </div>
56
+ )
57
+ }
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from 'react'
2
+ import { useId } from 'react'
3
+
4
+ export type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right'
5
+
6
+ export interface TooltipProps {
7
+ label: string
8
+ children: ReactNode
9
+ placement?: TooltipPlacement
10
+ className?: string
11
+ }
12
+
13
+ export function Tooltip({ label, children, placement = 'top', className = '' }: TooltipProps) {
14
+ const id = useId()
15
+ return (
16
+ <span className={`tooltip-wrapper tooltip-${placement} ${className}`.trim()}>
17
+ <span className="tooltip-trigger" aria-describedby={id}>
18
+ {children}
19
+ </span>
20
+ <span id={id} role="tooltip" className="tooltip-content">
21
+ {label}
22
+ </span>
23
+ </span>
24
+ )
25
+ }
@@ -0,0 +1,227 @@
1
+ import { useId, useRef, useState } from 'react'
2
+
3
+ export interface TransferListItem {
4
+ id: string
5
+ label: string
6
+ description?: string
7
+ }
8
+
9
+ export interface TransferListProps {
10
+ sourceLabel?: string
11
+ targetLabel?: string
12
+ source?: TransferListItem[]
13
+ target?: TransferListItem[]
14
+ defaultSource?: TransferListItem[]
15
+ defaultTarget?: TransferListItem[]
16
+ onChange?: (source: TransferListItem[], target: TransferListItem[]) => void
17
+ className?: string
18
+ }
19
+
20
+ export function TransferList({
21
+ sourceLabel = 'Available',
22
+ targetLabel = 'Selected',
23
+ source: srcProp,
24
+ target: tgtProp,
25
+ defaultSource = [],
26
+ defaultTarget = [],
27
+ onChange,
28
+ className = '',
29
+ }: TransferListProps) {
30
+ const gid = useId()
31
+ const [intSrc, setIntSrc] = useState<TransferListItem[]>(() => srcProp ?? defaultSource)
32
+ const [intTgt, setIntTgt] = useState<TransferListItem[]>(() => tgtProp ?? defaultTarget)
33
+ const srcItems = srcProp !== undefined ? srcProp : intSrc
34
+ const tgtItems = tgtProp !== undefined ? tgtProp : intTgt
35
+
36
+ const [selSrc, setSelSrc] = useState(new Set<string>())
37
+ const [selTgt, setSelTgt] = useState(new Set<string>())
38
+ const [focSrc, setFocSrc] = useState(0)
39
+ const [focTgt, setFocTgt] = useState(0)
40
+ const srcRefs = useRef<(HTMLLIElement | null)[]>([])
41
+ const tgtRefs = useRef<(HTMLLIElement | null)[]>([])
42
+
43
+ function commit(nextSrc: TransferListItem[], nextTgt: TransferListItem[]) {
44
+ if (srcProp === undefined) setIntSrc(nextSrc)
45
+ if (tgtProp === undefined) setIntTgt(nextTgt)
46
+ onChange?.(nextSrc, nextTgt)
47
+ }
48
+
49
+ function moveRight(ids: Set<string>) {
50
+ if (!ids.size) return
51
+ commit(
52
+ srcItems.filter((i) => !ids.has(i.id)),
53
+ [...tgtItems, ...srcItems.filter((i) => ids.has(i.id))],
54
+ )
55
+ setSelSrc(new Set())
56
+ setFocSrc(0)
57
+ }
58
+
59
+ function moveLeft(ids: Set<string>) {
60
+ if (!ids.size) return
61
+ commit(
62
+ [...srcItems, ...tgtItems.filter((i) => ids.has(i.id))],
63
+ tgtItems.filter((i) => !ids.has(i.id)),
64
+ )
65
+ setSelTgt(new Set())
66
+ setFocTgt(0)
67
+ }
68
+
69
+ function toggle(sel: Set<string>, set: (s: Set<string>) => void, id: string) {
70
+ const n = new Set(sel)
71
+ if (n.has(id)) {
72
+ n.delete(id)
73
+ } else {
74
+ n.add(id)
75
+ }
76
+ set(n)
77
+ }
78
+
79
+ function listKey(
80
+ e: React.KeyboardEvent,
81
+ items: TransferListItem[],
82
+ sel: Set<string>,
83
+ setSel: (s: Set<string>) => void,
84
+ foc: number,
85
+ setFoc: (n: number) => void,
86
+ refs: React.MutableRefObject<(HTMLLIElement | null)[]>,
87
+ onEnter: () => void,
88
+ ) {
89
+ if (!items.length) return
90
+ if (e.key === 'ArrowDown') {
91
+ e.preventDefault()
92
+ const n = Math.min(foc + 1, items.length - 1)
93
+ setFoc(n)
94
+ refs.current[n]?.focus()
95
+ } else if (e.key === 'ArrowUp') {
96
+ e.preventDefault()
97
+ const n = Math.max(foc - 1, 0)
98
+ setFoc(n)
99
+ refs.current[n]?.focus()
100
+ } else if (e.key === ' ') {
101
+ e.preventDefault()
102
+ const item = items[foc]
103
+ if (item) toggle(sel, setSel, item.id)
104
+ } else if (e.key === 'Enter') {
105
+ e.preventDefault()
106
+ onEnter()
107
+ }
108
+ }
109
+
110
+ function renderList(
111
+ id: string,
112
+ label: string,
113
+ items: TransferListItem[],
114
+ sel: Set<string>,
115
+ setSel: (s: Set<string>) => void,
116
+ foc: number,
117
+ setFoc: (n: number) => void,
118
+ refs: React.MutableRefObject<(HTMLLIElement | null)[]>,
119
+ onEnter: () => void,
120
+ ) {
121
+ return (
122
+ <div className="tl__panel">
123
+ <div className="tl__header" id={id}>
124
+ {label} <span className="tl__count">{items.length}</span>
125
+ </div>
126
+ <ul
127
+ role="listbox"
128
+ aria-labelledby={id}
129
+ aria-multiselectable="true"
130
+ className="tl__list"
131
+ onKeyDown={(e) => listKey(e, items, sel, setSel, foc, setFoc, refs, onEnter)}
132
+ >
133
+ {items.length === 0 ? (
134
+ <li className="tl__empty" role="option" aria-selected={false} aria-disabled="true">
135
+ Empty
136
+ </li>
137
+ ) : (
138
+ items.map((item, i) => (
139
+ <li
140
+ key={item.id}
141
+ ref={(el) => {
142
+ refs.current[i] = el
143
+ }}
144
+ role="option"
145
+ aria-selected={sel.has(item.id)}
146
+ tabIndex={i === foc ? 0 : -1}
147
+ className={`tl__item${sel.has(item.id) ? ' tl__item--sel' : ''}`}
148
+ onClick={() => {
149
+ setFoc(i)
150
+ toggle(sel, setSel, item.id)
151
+ }}
152
+ >
153
+ <span className="tl__item-label">{item.label}</span>
154
+ {item.description && <span className="tl__item-desc">{item.description}</span>}
155
+ </li>
156
+ ))
157
+ )}
158
+ </ul>
159
+ </div>
160
+ )
161
+ }
162
+
163
+ return (
164
+ <div className={`tl ${className}`.trim()}>
165
+ {renderList(
166
+ `${gid}-src`,
167
+ sourceLabel,
168
+ srcItems,
169
+ selSrc,
170
+ setSelSrc,
171
+ focSrc,
172
+ setFocSrc,
173
+ srcRefs,
174
+ () => moveRight(selSrc),
175
+ )}
176
+ <div className="tl__controls" role="group" aria-label="Transfer controls">
177
+ <button
178
+ type="button"
179
+ className="tl__btn"
180
+ aria-label={`Move all to ${targetLabel}`}
181
+ disabled={!srcItems.length}
182
+ onClick={() => moveRight(new Set(srcItems.map((i) => i.id)))}
183
+ >
184
+ {'»'}
185
+ </button>
186
+ <button
187
+ type="button"
188
+ className="tl__btn"
189
+ aria-label={`Move selected to ${targetLabel}`}
190
+ disabled={!selSrc.size}
191
+ onClick={() => moveRight(selSrc)}
192
+ >
193
+ {'›'}
194
+ </button>
195
+ <button
196
+ type="button"
197
+ className="tl__btn"
198
+ aria-label={`Move selected back to ${sourceLabel}`}
199
+ disabled={!selTgt.size}
200
+ onClick={() => moveLeft(selTgt)}
201
+ >
202
+ {'‹'}
203
+ </button>
204
+ <button
205
+ type="button"
206
+ className="tl__btn"
207
+ aria-label={`Move all back to ${sourceLabel}`}
208
+ disabled={!tgtItems.length}
209
+ onClick={() => moveLeft(new Set(tgtItems.map((i) => i.id)))}
210
+ >
211
+ {'«'}
212
+ </button>
213
+ </div>
214
+ {renderList(
215
+ `${gid}-tgt`,
216
+ targetLabel,
217
+ tgtItems,
218
+ selTgt,
219
+ setSelTgt,
220
+ focTgt,
221
+ setFocTgt,
222
+ tgtRefs,
223
+ () => moveLeft(selTgt),
224
+ )}
225
+ </div>
226
+ )
227
+ }