@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,211 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { ChangeEvent, KeyboardEvent } from 'react'
3
+
4
+ export interface ComboboxOption {
5
+ value: string
6
+ label: string
7
+ }
8
+
9
+ export interface ComboboxProps {
10
+ options: ComboboxOption[]
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 Combobox({
21
+ options,
22
+ value,
23
+ onChange,
24
+ label,
25
+ placeholder = 'Search…',
26
+ id,
27
+ disabled = false,
28
+ className = '',
29
+ }: ComboboxProps) {
30
+ const generatedId = useId()
31
+ const inputId = id ?? generatedId
32
+ const listboxId = `${inputId}-lb`
33
+
34
+ const [open, setOpen] = useState(false)
35
+ const [query, setQuery] = useState('')
36
+ const [activeIdx, setActiveIdx] = useState(-1)
37
+
38
+ const inputRef = useRef<HTMLInputElement>(null)
39
+ const wrapperRef = useRef<HTMLDivElement>(null)
40
+
41
+ const selectedOption = options.find((o) => o.value === value)
42
+
43
+ const filtered =
44
+ query.trim() === ''
45
+ ? options
46
+ : options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
47
+
48
+ const activeOptionId =
49
+ activeIdx >= 0 && filtered[activeIdx] ? `${listboxId}-opt-${activeIdx}` : undefined
50
+
51
+ useEffect(() => {
52
+ if (!open) return
53
+ function handler(e: MouseEvent) {
54
+ if (!wrapperRef.current?.contains(e.target as Node)) {
55
+ setOpen(false)
56
+ setQuery('')
57
+ }
58
+ }
59
+ document.addEventListener('mousedown', handler)
60
+ return () => document.removeEventListener('mousedown', handler)
61
+ }, [open])
62
+
63
+ function openList() {
64
+ if (disabled) return
65
+ setQuery(selectedOption?.label ?? '')
66
+ setActiveIdx(options.findIndex((o) => o.value === value))
67
+ setOpen(true)
68
+ }
69
+
70
+ function pick(opt: ComboboxOption) {
71
+ onChange?.(opt.value)
72
+ setOpen(false)
73
+ setQuery('')
74
+ inputRef.current?.focus()
75
+ }
76
+
77
+ function onInputChange(e: ChangeEvent<HTMLInputElement>) {
78
+ setQuery(e.target.value)
79
+ setActiveIdx(-1)
80
+ if (!open) setOpen(true)
81
+ }
82
+
83
+ function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
84
+ if (disabled) return
85
+ if (e.key === 'Escape') {
86
+ setOpen(false)
87
+ setQuery('')
88
+ return
89
+ }
90
+ if (!open) {
91
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
92
+ e.preventDefault()
93
+ openList()
94
+ }
95
+ return
96
+ }
97
+ if (e.key === 'ArrowDown') {
98
+ e.preventDefault()
99
+ setActiveIdx((i) => Math.min(i + 1, filtered.length - 1))
100
+ } else if (e.key === 'ArrowUp') {
101
+ e.preventDefault()
102
+ setActiveIdx((i) => Math.max(i - 1, 0))
103
+ } else if (e.key === 'Home') {
104
+ e.preventDefault()
105
+ setActiveIdx(0)
106
+ } else if (e.key === 'End') {
107
+ e.preventDefault()
108
+ setActiveIdx(filtered.length - 1)
109
+ } else if (e.key === 'Enter') {
110
+ e.preventDefault()
111
+ if (activeIdx >= 0 && filtered[activeIdx]) pick(filtered[activeIdx])
112
+ } else if (e.key === 'Tab') {
113
+ setOpen(false)
114
+ setQuery('')
115
+ }
116
+ }
117
+
118
+ const displayValue = open ? query : (selectedOption?.label ?? '')
119
+
120
+ return (
121
+ <div ref={wrapperRef} className={`combobox-wrapper ${className}`.trim()}>
122
+ {label && (
123
+ <label htmlFor={inputId} className="sr-only">
124
+ {label}
125
+ </label>
126
+ )}
127
+ <div className="combobox-field">
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={placeholder}
141
+ value={displayValue}
142
+ onChange={onInputChange}
143
+ onFocus={openList}
144
+ onKeyDown={onKeyDown}
145
+ className="combobox-input"
146
+ />
147
+ <button
148
+ type="button"
149
+ tabIndex={-1}
150
+ aria-hidden="true"
151
+ className="combobox-chevron-btn"
152
+ onClick={() => {
153
+ if (open) {
154
+ setOpen(false)
155
+ setQuery('')
156
+ } else {
157
+ inputRef.current?.focus()
158
+ openList()
159
+ }
160
+ }}
161
+ >
162
+ <svg
163
+ className={`select-chevron${open ? ' combobox-chevron-open' : ''}`}
164
+ viewBox="0 0 16 16"
165
+ aria-hidden="true"
166
+ focusable="false"
167
+ >
168
+ <path
169
+ d="M4 6l4 4 4-4"
170
+ stroke="currentColor"
171
+ strokeWidth="1.5"
172
+ strokeLinecap="round"
173
+ strokeLinejoin="round"
174
+ fill="none"
175
+ />
176
+ </svg>
177
+ </button>
178
+ </div>
179
+ {open && (
180
+ <ul
181
+ id={listboxId}
182
+ role="listbox"
183
+ aria-label={label}
184
+ className="select-listbox combobox-listbox"
185
+ >
186
+ {filtered.length === 0 ? (
187
+ <li className="combobox-empty">No results</li>
188
+ ) : (
189
+ filtered.map((opt, idx) => (
190
+ <li
191
+ key={opt.value}
192
+ id={`${listboxId}-opt-${idx}`}
193
+ role="option"
194
+ aria-selected={opt.value === value}
195
+ data-active={idx === activeIdx ? 'true' : undefined}
196
+ className="select-option"
197
+ onMouseDown={(e) => {
198
+ e.preventDefault()
199
+ pick(opt)
200
+ }}
201
+ onMouseEnter={() => setActiveIdx(idx)}
202
+ >
203
+ {opt.label}
204
+ </li>
205
+ ))
206
+ )}
207
+ </ul>
208
+ )}
209
+ </div>
210
+ )
211
+ }
@@ -0,0 +1,168 @@
1
+ import { useState, useRef, useId, useEffect, type ReactNode } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+ import { Kbd } from './Kbd.js'
4
+
5
+ export interface CommandItem {
6
+ id: string
7
+ label: string
8
+ group?: string
9
+ icon?: ReactNode
10
+ shortcut?: string
11
+ }
12
+
13
+ export interface CommandPaletteProps {
14
+ open: boolean
15
+ onClose: () => void
16
+ items: CommandItem[]
17
+ onSelect: (id: string) => void
18
+ placeholder?: string
19
+ }
20
+
21
+ export function CommandPalette({
22
+ open,
23
+ onClose,
24
+ items,
25
+ onSelect,
26
+ placeholder = 'Search pages and actions…',
27
+ }: CommandPaletteProps) {
28
+ const [query, setQuery] = useState('')
29
+ const [activeIdx, setActiveIdx] = useState(0)
30
+ const inputRef = useRef<HTMLInputElement>(null)
31
+ const listId = useId()
32
+
33
+ const filtered =
34
+ query.trim() === ''
35
+ ? items
36
+ : items.filter((i) => i.label.toLowerCase().includes(query.toLowerCase()))
37
+
38
+ const grouped = filtered.reduce<{ group: string; items: CommandItem[] }[]>((acc, item) => {
39
+ const g = item.group ?? ''
40
+ const found = acc.find((a) => a.group === g)
41
+ if (found) found.items.push(item)
42
+ else acc.push({ group: g, items: [item] })
43
+ return acc
44
+ }, [])
45
+
46
+ useEffect(() => {
47
+ if (!open) return
48
+ setQuery('')
49
+ setActiveIdx(0)
50
+ const frame = requestAnimationFrame(() => inputRef.current?.focus())
51
+ return () => cancelAnimationFrame(frame)
52
+ }, [open])
53
+
54
+ useEffect(() => {
55
+ setActiveIdx(0)
56
+ }, [query])
57
+
58
+ function commit(id: string) {
59
+ onSelect(id)
60
+ onClose()
61
+ setQuery('')
62
+ }
63
+
64
+ function handleKey(e: KeyboardEvent<HTMLInputElement>) {
65
+ if (e.key === 'ArrowDown') {
66
+ e.preventDefault()
67
+ setActiveIdx((i) => Math.min(i + 1, filtered.length - 1))
68
+ } else if (e.key === 'ArrowUp') {
69
+ e.preventDefault()
70
+ setActiveIdx((i) => Math.max(i - 1, 0))
71
+ } else if (e.key === 'Enter') {
72
+ e.preventDefault()
73
+ const item = filtered[activeIdx]
74
+ if (item) commit(item.id)
75
+ } else if (e.key === 'Escape') {
76
+ e.preventDefault()
77
+ onClose()
78
+ }
79
+ }
80
+
81
+ if (!open) return null
82
+
83
+ return (
84
+ <div
85
+ className="cp-backdrop"
86
+ onClick={(e) => {
87
+ if (e.target === e.currentTarget) onClose()
88
+ }}
89
+ >
90
+ <div role="dialog" aria-modal="true" aria-label="Command palette" className="cp-panel">
91
+ <div className="cp-search">
92
+ <svg
93
+ className="cp-search-icon"
94
+ width="16"
95
+ height="16"
96
+ viewBox="0 0 16 16"
97
+ fill="none"
98
+ stroke="currentColor"
99
+ strokeWidth="1.5"
100
+ strokeLinecap="round"
101
+ strokeLinejoin="round"
102
+ aria-hidden="true"
103
+ focusable="false"
104
+ >
105
+ <circle cx="6.5" cy="6.5" r="4.5" />
106
+ <path d="M10.5 10.5l3 3" />
107
+ </svg>
108
+ <input
109
+ ref={inputRef}
110
+ className="cp-input"
111
+ type="text"
112
+ role="combobox"
113
+ aria-expanded={filtered.length > 0}
114
+ aria-autocomplete="list"
115
+ aria-controls={listId}
116
+ aria-activedescendant={filtered[activeIdx] ? `${listId}-${activeIdx}` : undefined}
117
+ placeholder={placeholder}
118
+ value={query}
119
+ onChange={(e) => setQuery(e.target.value)}
120
+ onKeyDown={handleKey}
121
+ />
122
+ <span className="cp-kbd">Esc</span>
123
+ </div>
124
+ <ul id={listId} role="listbox" aria-label="Results" className="cp-list">
125
+ {filtered.length === 0 ? (
126
+ <li className="cp-empty">No results for &ldquo;{query}&rdquo;</li>
127
+ ) : (
128
+ grouped.map(({ group, items: groupItems }) => (
129
+ <li key={group} role="presentation">
130
+ {group && <div className="cp-group-label">{group}</div>}
131
+ {groupItems.map((item) => {
132
+ const idx = filtered.indexOf(item)
133
+ return (
134
+ <div
135
+ key={item.id}
136
+ id={`${listId}-${idx}`}
137
+ role="option"
138
+ aria-selected={idx === activeIdx}
139
+ data-active={idx === activeIdx ? 'true' : undefined}
140
+ className="cp-item"
141
+ onClick={() => commit(item.id)}
142
+ onMouseEnter={() => setActiveIdx(idx)}
143
+ >
144
+ {item.icon && (
145
+ <span className="cp-item-icon" aria-hidden="true">
146
+ {item.icon}
147
+ </span>
148
+ )}
149
+ {item.label}
150
+ {item.shortcut && (
151
+ <span
152
+ className="cp-item-shortcut"
153
+ aria-label={item.shortcut.replace(/\+/g, ' then ')}
154
+ >
155
+ <Kbd keys={item.shortcut} />
156
+ </span>
157
+ )}
158
+ </div>
159
+ )
160
+ })}
161
+ </li>
162
+ ))
163
+ )}
164
+ </ul>
165
+ </div>
166
+ </div>
167
+ )
168
+ }
@@ -0,0 +1,114 @@
1
+ import { useEffect, useId, useRef } from 'react'
2
+
3
+ export type ConfirmVariant = 'danger' | 'warning' | 'info'
4
+
5
+ export interface ConfirmDialogProps {
6
+ open: boolean
7
+ title: string
8
+ description?: string
9
+ confirmLabel?: string
10
+ cancelLabel?: string
11
+ variant?: ConfirmVariant
12
+ onConfirm: () => void
13
+ onCancel: () => void
14
+ }
15
+
16
+ const FOCUSABLE =
17
+ 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
18
+
19
+ const VARIANT_CLASS: Record<ConfirmVariant, string> = {
20
+ danger: 'btn cd-confirm cd-confirm--danger',
21
+ warning: 'btn cd-confirm cd-confirm--warning',
22
+ info: 'btn cd-confirm cd-confirm--info',
23
+ }
24
+
25
+ export function ConfirmDialog({
26
+ open,
27
+ title,
28
+ description,
29
+ confirmLabel = 'Confirm',
30
+ cancelLabel = 'Cancel',
31
+ variant = 'info',
32
+ onConfirm,
33
+ onCancel,
34
+ }: ConfirmDialogProps) {
35
+ const titleId = useId()
36
+ const descId = useId()
37
+ const dialogRef = useRef<HTMLDivElement>(null)
38
+
39
+ useEffect(() => {
40
+ if (!open) return
41
+ const prev = document.activeElement as HTMLElement | null
42
+ const prevOverflow = document.body.style.overflow
43
+ document.body.style.overflow = 'hidden'
44
+ const dialog = dialogRef.current
45
+ const items = dialog ? Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE)) : []
46
+ ;(items[0] ?? dialog)?.focus()
47
+
48
+ function onKey(e: KeyboardEvent) {
49
+ if (e.key === 'Escape') {
50
+ onCancel()
51
+ return
52
+ }
53
+ if (e.key !== 'Tab' || !dialog) return
54
+ const els = Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE))
55
+ if (!els.length) {
56
+ e.preventDefault()
57
+ return
58
+ }
59
+ const first = els[0]!
60
+ const last = els[els.length - 1]!
61
+ const active = document.activeElement
62
+ if (e.shiftKey && active === first) {
63
+ e.preventDefault()
64
+ last.focus()
65
+ } else if (!e.shiftKey && active === last) {
66
+ e.preventDefault()
67
+ first.focus()
68
+ }
69
+ }
70
+
71
+ window.addEventListener('keydown', onKey)
72
+ return () => {
73
+ window.removeEventListener('keydown', onKey)
74
+ document.body.style.overflow = prevOverflow
75
+ prev?.focus?.()
76
+ }
77
+ }, [open, onCancel])
78
+
79
+ return (
80
+ <div
81
+ className={`backdrop ${open ? 'show' : ''}`}
82
+ onClick={(e) => {
83
+ if (e.target === e.currentTarget) onCancel()
84
+ }}
85
+ >
86
+ <div
87
+ ref={dialogRef}
88
+ className="modal cd-dialog"
89
+ role="alertdialog"
90
+ aria-modal="true"
91
+ aria-labelledby={titleId}
92
+ aria-describedby={description ? descId : undefined}
93
+ tabIndex={-1}
94
+ >
95
+ <div className="modal-h">
96
+ <h2 id={titleId}>{title}</h2>
97
+ </div>
98
+ {description && (
99
+ <div id={descId} className="cd-body">
100
+ {description}
101
+ </div>
102
+ )}
103
+ <div className="modal-f">
104
+ <button type="button" className="btn btn-ghost" onClick={onCancel}>
105
+ {cancelLabel}
106
+ </button>
107
+ <button type="button" className={VARIANT_CLASS[variant]} onClick={onConfirm}>
108
+ {confirmLabel}
109
+ </button>
110
+ </div>
111
+ </div>
112
+ </div>
113
+ )
114
+ }
@@ -0,0 +1,127 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+
4
+ export interface ContextMenuItem {
5
+ id: string
6
+ label: string
7
+ danger?: boolean
8
+ disabled?: boolean
9
+ }
10
+
11
+ export type ContextMenuEntry = ContextMenuItem | { separator: true }
12
+
13
+ export interface ContextMenuProps {
14
+ items: ContextMenuEntry[]
15
+ x: number
16
+ y: number
17
+ open: boolean
18
+ onClose: () => void
19
+ onAction: (id: string) => void
20
+ label?: string
21
+ }
22
+
23
+ export function ContextMenu({
24
+ items,
25
+ x,
26
+ y,
27
+ open,
28
+ onClose,
29
+ onAction,
30
+ label = 'Context menu',
31
+ }: ContextMenuProps) {
32
+ const uid = useId()
33
+ const menuId = `${uid}-ctxmenu`
34
+ const [activeIdx, setActiveIdx] = useState(0)
35
+ const menuRef = useRef<HTMLUListElement>(null)
36
+
37
+ const actionItems = items.reduce<ContextMenuItem[]>((acc, e) => {
38
+ if (!('separator' in e)) acc.push(e)
39
+ return acc
40
+ }, [])
41
+
42
+ useEffect(() => {
43
+ if (!open) return
44
+ setActiveIdx(0)
45
+ requestAnimationFrame(() => menuRef.current?.focus())
46
+ function handler(e: MouseEvent) {
47
+ if (!menuRef.current?.contains(e.target as Node)) onClose()
48
+ }
49
+ document.addEventListener('mousedown', handler)
50
+ return () => document.removeEventListener('mousedown', handler)
51
+ }, [open, onClose])
52
+
53
+ function activate(id: string) {
54
+ onAction(id)
55
+ onClose()
56
+ }
57
+
58
+ function onKeyDown(e: KeyboardEvent) {
59
+ if (e.key === 'Escape') {
60
+ e.preventDefault()
61
+ onClose()
62
+ } else if (e.key === 'ArrowDown') {
63
+ e.preventDefault()
64
+ setActiveIdx((i) => Math.min(i + 1, actionItems.length - 1))
65
+ } else if (e.key === 'ArrowUp') {
66
+ e.preventDefault()
67
+ setActiveIdx((i) => Math.max(i - 1, 0))
68
+ } else if (e.key === 'Home') {
69
+ e.preventDefault()
70
+ setActiveIdx(0)
71
+ } else if (e.key === 'End') {
72
+ e.preventDefault()
73
+ setActiveIdx(actionItems.length - 1)
74
+ } else if (e.key === 'Enter' || e.key === ' ') {
75
+ e.preventDefault()
76
+ const item = actionItems[activeIdx]
77
+ if (item && !item.disabled) activate(item.id)
78
+ } else if (e.key === 'Tab') {
79
+ onClose()
80
+ }
81
+ }
82
+
83
+ if (!open) return null
84
+
85
+ const menuW = 168
86
+ const menuH = items.length * 30 + 8
87
+ const cx = Math.min(x, window.innerWidth - menuW - 8)
88
+ const cy = Math.min(y, window.innerHeight - menuH - 8)
89
+
90
+ return (
91
+ <ul
92
+ ref={menuRef}
93
+ id={menuId}
94
+ role="menu"
95
+ aria-label={label}
96
+ tabIndex={-1}
97
+ className="ctx-menu"
98
+ style={{ left: cx, top: cy }}
99
+ onKeyDown={onKeyDown}
100
+ >
101
+ {items.map((entry, i) => {
102
+ if ('separator' in entry) {
103
+ return <li key={i} role="separator" className="ctx-menu-sep" />
104
+ }
105
+ const ai = actionItems.indexOf(entry)
106
+ return (
107
+ <li
108
+ key={entry.id}
109
+ role="menuitem"
110
+ tabIndex={-1}
111
+ aria-disabled={entry.disabled || undefined}
112
+ data-active={ai === activeIdx ? 'true' : undefined}
113
+ data-danger={entry.danger || undefined}
114
+ className="ctx-menu-item"
115
+ onMouseEnter={() => setActiveIdx(ai)}
116
+ onMouseDown={(e) => {
117
+ e.preventDefault()
118
+ if (!entry.disabled) activate(entry.id)
119
+ }}
120
+ >
121
+ {entry.label}
122
+ </li>
123
+ )
124
+ })}
125
+ </ul>
126
+ )
127
+ }
@@ -0,0 +1,69 @@
1
+ import { useEffect, useId, useRef, useState } from 'react'
2
+
3
+ export interface ContextualHelpProps {
4
+ title: string
5
+ body: string
6
+ placement?: 'top' | 'bottom'
7
+ }
8
+
9
+ export function ContextualHelp({ title, body, placement = 'top' }: ContextualHelpProps) {
10
+ const titleId = useId()
11
+ const [open, setOpen] = useState(false)
12
+ const rootRef = useRef<HTMLSpanElement>(null)
13
+ const triggerRef = useRef<HTMLButtonElement>(null)
14
+ const panelRef = useRef<HTMLDivElement>(null)
15
+
16
+ useEffect(() => {
17
+ if (!open) return
18
+ panelRef.current?.focus()
19
+
20
+ function onKey(e: KeyboardEvent) {
21
+ if (e.key === 'Escape') {
22
+ e.preventDefault()
23
+ setOpen(false)
24
+ triggerRef.current?.focus()
25
+ }
26
+ }
27
+
28
+ function onPointer(e: PointerEvent) {
29
+ if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
30
+ }
31
+
32
+ window.addEventListener('keydown', onKey)
33
+ document.addEventListener('pointerdown', onPointer)
34
+ return () => {
35
+ window.removeEventListener('keydown', onKey)
36
+ document.removeEventListener('pointerdown', onPointer)
37
+ }
38
+ }, [open])
39
+
40
+ return (
41
+ <span ref={rootRef} className="ch-root">
42
+ <button
43
+ ref={triggerRef}
44
+ type="button"
45
+ className="ch-trigger"
46
+ aria-label="Help"
47
+ aria-haspopup="dialog"
48
+ aria-expanded={open}
49
+ onClick={() => setOpen((o) => !o)}
50
+ >
51
+ ?
52
+ </button>
53
+ {open && (
54
+ <div
55
+ ref={panelRef}
56
+ role="dialog"
57
+ aria-labelledby={titleId}
58
+ className={`ch-panel ch-panel--${placement}`}
59
+ tabIndex={-1}
60
+ >
61
+ <div id={titleId} className="ch-title">
62
+ {title}
63
+ </div>
64
+ <div className="ch-body">{body}</div>
65
+ </div>
66
+ )}
67
+ </span>
68
+ )
69
+ }