@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,315 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+ import type { KeyboardEvent } from 'react'
3
+
4
+ const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
5
+ const MONTHS = [
6
+ 'January',
7
+ 'February',
8
+ 'March',
9
+ 'April',
10
+ 'May',
11
+ 'June',
12
+ 'July',
13
+ 'August',
14
+ 'September',
15
+ 'October',
16
+ 'November',
17
+ 'December',
18
+ ]
19
+
20
+ function isSameDay(a: Date, b: Date) {
21
+ return (
22
+ a.getFullYear() === b.getFullYear() &&
23
+ a.getMonth() === b.getMonth() &&
24
+ a.getDate() === b.getDate()
25
+ )
26
+ }
27
+
28
+ function formatDate(d: Date) {
29
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
30
+ const dd = String(d.getDate()).padStart(2, '0')
31
+ return `${mm}/${dd}/${d.getFullYear()}`
32
+ }
33
+
34
+ function buildGrid(year: number, month: number): Date[] {
35
+ const first = new Date(year, month, 1)
36
+ const last = new Date(year, month + 1, 0)
37
+ const grid: Date[] = []
38
+ for (let i = first.getDay() - 1; i >= 0; i--) {
39
+ grid.push(new Date(year, month, -i))
40
+ }
41
+ for (let d = 1; d <= last.getDate(); d++) {
42
+ grid.push(new Date(year, month, d))
43
+ }
44
+ while (grid.length < 42) {
45
+ grid.push(new Date(year, month + 1, grid.length - first.getDay() - last.getDate() + 1))
46
+ }
47
+ return grid
48
+ }
49
+
50
+ export interface DatePickerProps {
51
+ value?: Date | null
52
+ onChange?: (date: Date) => void
53
+ placeholder?: string
54
+ disabled?: boolean
55
+ }
56
+
57
+ export function DatePicker({
58
+ value,
59
+ onChange,
60
+ placeholder = 'Pick a date',
61
+ disabled = false,
62
+ }: DatePickerProps) {
63
+ const today = new Date()
64
+ const [open, setOpen] = useState(false)
65
+ const [viewYear, setViewYear] = useState(() => value?.getFullYear() ?? today.getFullYear())
66
+ const [viewMonth, setViewMonth] = useState(() => value?.getMonth() ?? today.getMonth())
67
+ const [focusIdx, setFocusIdx] = useState(0)
68
+
69
+ const uid = useId()
70
+ const calId = `${uid}-cal`
71
+ const triggerRef = useRef<HTMLButtonElement>(null)
72
+ const calRef = useRef<HTMLDivElement>(null)
73
+ const dayRefs = useRef<(HTMLButtonElement | null)[]>([])
74
+
75
+ const grid = buildGrid(viewYear, viewMonth)
76
+
77
+ function openCal() {
78
+ if (disabled) return
79
+ const initial = value
80
+ ? grid.findIndex((d) => isSameDay(d, value))
81
+ : grid.findIndex((d) => isSameDay(d, today))
82
+ setFocusIdx(Math.max(0, initial))
83
+ setOpen(true)
84
+ }
85
+
86
+ function closeCal() {
87
+ setOpen(false)
88
+ triggerRef.current?.focus()
89
+ }
90
+
91
+ function select(d: Date) {
92
+ onChange?.(d)
93
+ closeCal()
94
+ }
95
+
96
+ function prevMonth() {
97
+ if (viewMonth === 0) {
98
+ setViewYear((y) => y - 1)
99
+ setViewMonth(11)
100
+ } else setViewMonth((m) => m - 1)
101
+ }
102
+
103
+ function nextMonth() {
104
+ if (viewMonth === 11) {
105
+ setViewYear((y) => y + 1)
106
+ setViewMonth(0)
107
+ } else setViewMonth((m) => m + 1)
108
+ }
109
+
110
+ useEffect(() => {
111
+ if (!open) return
112
+ function handler(e: MouseEvent) {
113
+ if (
114
+ !calRef.current?.contains(e.target as Node) &&
115
+ !triggerRef.current?.contains(e.target as Node)
116
+ )
117
+ setOpen(false)
118
+ }
119
+ document.addEventListener('mousedown', handler)
120
+ return () => document.removeEventListener('mousedown', handler)
121
+ }, [open])
122
+
123
+ useEffect(() => {
124
+ if (open) dayRefs.current[focusIdx]?.focus()
125
+ }, [open, focusIdx])
126
+
127
+ function onGridKey(e: KeyboardEvent, idx: number) {
128
+ let next = idx
129
+ if (e.key === 'ArrowRight') {
130
+ e.preventDefault()
131
+ next = idx + 1
132
+ } else if (e.key === 'ArrowLeft') {
133
+ e.preventDefault()
134
+ next = idx - 1
135
+ } else if (e.key === 'ArrowDown') {
136
+ e.preventDefault()
137
+ next = idx + 7
138
+ } else if (e.key === 'ArrowUp') {
139
+ e.preventDefault()
140
+ next = idx - 7
141
+ } else if (e.key === 'Home') {
142
+ e.preventDefault()
143
+ next = Math.floor(idx / 7) * 7
144
+ } else if (e.key === 'End') {
145
+ e.preventDefault()
146
+ next = Math.floor(idx / 7) * 7 + 6
147
+ } else if (e.key === 'PageDown') {
148
+ e.preventDefault()
149
+ nextMonth()
150
+ return
151
+ } else if (e.key === 'PageUp') {
152
+ e.preventDefault()
153
+ prevMonth()
154
+ return
155
+ } else if (e.key === 'Enter' || e.key === ' ') {
156
+ e.preventDefault()
157
+ select(grid[idx])
158
+ return
159
+ } else if (e.key === 'Escape') {
160
+ e.preventDefault()
161
+ closeCal()
162
+ return
163
+ } else if (e.key === 'Tab') {
164
+ closeCal()
165
+ return
166
+ } else return
167
+ if (next < 0) {
168
+ prevMonth()
169
+ return
170
+ }
171
+ if (next >= grid.length) {
172
+ nextMonth()
173
+ return
174
+ }
175
+ setFocusIdx(next)
176
+ }
177
+
178
+ return (
179
+ <div className="dp-root">
180
+ <button
181
+ ref={triggerRef}
182
+ type="button"
183
+ className="btn dp-trigger"
184
+ aria-haspopup="dialog"
185
+ aria-expanded={open}
186
+ aria-controls={open ? calId : undefined}
187
+ disabled={disabled}
188
+ onClick={open ? closeCal : openCal}
189
+ >
190
+ <svg
191
+ width="14"
192
+ height="14"
193
+ viewBox="0 0 14 14"
194
+ fill="none"
195
+ stroke="currentColor"
196
+ strokeWidth="1.4"
197
+ strokeLinecap="round"
198
+ strokeLinejoin="round"
199
+ aria-hidden="true"
200
+ focusable="false"
201
+ >
202
+ <rect x="1" y="2" width="12" height="11" rx="1.5" />
203
+ <path d="M1 6h12M4 1v2M10 1v2" />
204
+ </svg>
205
+ {value ? formatDate(value) : placeholder}
206
+ </button>
207
+ {open && (
208
+ <div
209
+ id={calId}
210
+ ref={calRef}
211
+ role="dialog"
212
+ aria-modal="true"
213
+ aria-label={`Choose date, ${MONTHS[viewMonth]} ${viewYear}`}
214
+ className="dp-cal"
215
+ >
216
+ <div className="dp-header">
217
+ <button
218
+ type="button"
219
+ className="dp-nav"
220
+ onClick={prevMonth}
221
+ aria-label="Previous month"
222
+ >
223
+ <svg
224
+ width="8"
225
+ height="12"
226
+ viewBox="0 0 8 12"
227
+ fill="none"
228
+ stroke="currentColor"
229
+ strokeWidth="1.5"
230
+ strokeLinecap="round"
231
+ strokeLinejoin="round"
232
+ aria-hidden="true"
233
+ focusable="false"
234
+ >
235
+ <path d="M7 1L2 6l5 5" />
236
+ </svg>
237
+ </button>
238
+ <span className="dp-month-label" aria-live="polite">
239
+ {MONTHS[viewMonth]} {viewYear}
240
+ </span>
241
+ <button type="button" className="dp-nav" onClick={nextMonth} aria-label="Next month">
242
+ <svg
243
+ width="8"
244
+ height="12"
245
+ viewBox="0 0 8 12"
246
+ fill="none"
247
+ stroke="currentColor"
248
+ strokeWidth="1.5"
249
+ strokeLinecap="round"
250
+ strokeLinejoin="round"
251
+ aria-hidden="true"
252
+ focusable="false"
253
+ >
254
+ <path d="M1 1l5 5-5 5" />
255
+ </svg>
256
+ </button>
257
+ </div>
258
+ <table role="grid" className="dp-grid" aria-label={`${MONTHS[viewMonth]} ${viewYear}`}>
259
+ <thead>
260
+ <tr>
261
+ {DAYS.map((d) => (
262
+ <th key={d} scope="col" abbr={d}>
263
+ {d}
264
+ </th>
265
+ ))}
266
+ </tr>
267
+ </thead>
268
+ <tbody>
269
+ {Array.from({ length: 6 }, (_, row) => (
270
+ <tr key={row}>
271
+ {Array.from({ length: 7 }, (_, col) => {
272
+ const idx = row * 7 + col
273
+ const day = grid[idx]
274
+ const outside = day.getMonth() !== viewMonth
275
+ const isToday = isSameDay(day, today)
276
+ const isSelected = value ? isSameDay(day, value) : false
277
+ return (
278
+ <td key={col} aria-selected={isSelected}>
279
+ <button
280
+ ref={(el) => {
281
+ dayRefs.current[idx] = el
282
+ }}
283
+ type="button"
284
+ tabIndex={idx === focusIdx ? 0 : -1}
285
+ className={[
286
+ 'dp-day',
287
+ outside && 'dp-day--outside',
288
+ isToday && !isSelected && 'dp-day--today',
289
+ isSelected && 'dp-day--selected',
290
+ ]
291
+ .filter(Boolean)
292
+ .join(' ')}
293
+ aria-label={day.toLocaleDateString('en-US', {
294
+ weekday: 'long',
295
+ month: 'long',
296
+ day: 'numeric',
297
+ year: 'numeric',
298
+ })}
299
+ onKeyDown={(e) => onGridKey(e, idx)}
300
+ onClick={() => select(day)}
301
+ >
302
+ {day.getDate()}
303
+ </button>
304
+ </td>
305
+ )
306
+ })}
307
+ </tr>
308
+ ))}
309
+ </tbody>
310
+ </table>
311
+ </div>
312
+ )}
313
+ </div>
314
+ )
315
+ }
@@ -0,0 +1,327 @@
1
+ import { useState, useRef, useEffect, useId } from 'react'
2
+
3
+ const DAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
4
+ const MONTHS = [
5
+ 'January',
6
+ 'February',
7
+ 'March',
8
+ 'April',
9
+ 'May',
10
+ 'June',
11
+ 'July',
12
+ 'August',
13
+ 'September',
14
+ 'October',
15
+ 'November',
16
+ 'December',
17
+ ]
18
+
19
+ function isSameDay(a: Date, b: Date) {
20
+ return (
21
+ a.getFullYear() === b.getFullYear() &&
22
+ a.getMonth() === b.getMonth() &&
23
+ a.getDate() === b.getDate()
24
+ )
25
+ }
26
+
27
+ function formatDate(d: Date) {
28
+ const mm = String(d.getMonth() + 1).padStart(2, '0')
29
+ const dd = String(d.getDate()).padStart(2, '0')
30
+ return `${mm}/${dd}/${d.getFullYear()}`
31
+ }
32
+
33
+ function buildGrid(year: number, month: number): Date[] {
34
+ const first = new Date(year, month, 1)
35
+ const last = new Date(year, month + 1, 0)
36
+ const grid: Date[] = []
37
+ for (let i = first.getDay() - 1; i >= 0; i--) {
38
+ grid.push(new Date(year, month, -i))
39
+ }
40
+ for (let d = 1; d <= last.getDate(); d++) {
41
+ grid.push(new Date(year, month, d))
42
+ }
43
+ while (grid.length < 42) {
44
+ grid.push(new Date(year, month + 1, grid.length - first.getDay() - last.getDate() + 1))
45
+ }
46
+ return grid
47
+ }
48
+
49
+ export interface DateRange {
50
+ start: Date | null
51
+ end: Date | null
52
+ }
53
+
54
+ export interface DateRangePickerProps {
55
+ value?: DateRange
56
+ onChange?: (range: DateRange) => void
57
+ placeholder?: string
58
+ disabled?: boolean
59
+ }
60
+
61
+ export function DateRangePicker({
62
+ value,
63
+ onChange,
64
+ placeholder = 'Select date range',
65
+ disabled = false,
66
+ }: DateRangePickerProps) {
67
+ const today = new Date()
68
+ const [open, setOpen] = useState(false)
69
+ const [picking, setPicking] = useState<Date | null>(null)
70
+ const [hoverDate, setHoverDate] = useState<Date | null>(null)
71
+ const [viewYear, setViewYear] = useState(today.getFullYear())
72
+ const [viewMonth, setViewMonth] = useState(today.getMonth())
73
+
74
+ const uid = useId()
75
+ const dlgId = `${uid}-drp`
76
+ const triggerRef = useRef<HTMLButtonElement>(null)
77
+ const dlgRef = useRef<HTMLDivElement>(null)
78
+
79
+ const rightMonth = viewMonth === 11 ? 0 : viewMonth + 1
80
+ const rightYear = viewMonth === 11 ? viewYear + 1 : viewYear
81
+ const leftGrid = buildGrid(viewYear, viewMonth)
82
+ const rightGrid = buildGrid(rightYear, rightMonth)
83
+
84
+ function prevMonth() {
85
+ if (viewMonth === 0) {
86
+ setViewYear((y) => y - 1)
87
+ setViewMonth(11)
88
+ } else setViewMonth((m) => m - 1)
89
+ }
90
+
91
+ function nextMonth() {
92
+ if (viewMonth === 11) {
93
+ setViewYear((y) => y + 1)
94
+ setViewMonth(0)
95
+ } else setViewMonth((m) => m + 1)
96
+ }
97
+
98
+ function handleDayClick(day: Date) {
99
+ if (!picking) {
100
+ setPicking(day)
101
+ onChange?.({ start: day, end: null })
102
+ } else {
103
+ const [s, e] = day < picking ? [day, picking] : [picking, day]
104
+ setPicking(null)
105
+ setHoverDate(null)
106
+ onChange?.({ start: s, end: e })
107
+ setOpen(false)
108
+ triggerRef.current?.focus()
109
+ }
110
+ }
111
+
112
+ function isInRange(day: Date): boolean {
113
+ const start = picking ?? value?.start ?? null
114
+ const end = picking ? hoverDate : (value?.end ?? null)
115
+ if (!start || !end) return false
116
+ const lo = start < end ? start : end
117
+ const hi = start < end ? end : start
118
+ return day > lo && day < hi
119
+ }
120
+
121
+ function isStart(day: Date): boolean {
122
+ const start = picking ?? value?.start ?? null
123
+ return start ? isSameDay(day, start) : false
124
+ }
125
+
126
+ function isEnd(day: Date): boolean {
127
+ if (picking) return hoverDate ? isSameDay(day, hoverDate) : false
128
+ return value?.end ? isSameDay(day, value.end) : false
129
+ }
130
+
131
+ function formatRange() {
132
+ if (!value?.start) return null
133
+ if (!value.end) return `${formatDate(value.start)} –`
134
+ return `${formatDate(value.start)} – ${formatDate(value.end)}`
135
+ }
136
+
137
+ useEffect(() => {
138
+ if (!open) return
139
+ function handler(e: MouseEvent) {
140
+ if (
141
+ !dlgRef.current?.contains(e.target as Node) &&
142
+ !triggerRef.current?.contains(e.target as Node)
143
+ ) {
144
+ setOpen(false)
145
+ setPicking(null)
146
+ }
147
+ }
148
+ document.addEventListener('mousedown', handler)
149
+ return () => document.removeEventListener('mousedown', handler)
150
+ }, [open])
151
+
152
+ function renderCalendar(grid: Date[], gridMonth: number, gridYear: number) {
153
+ return (
154
+ <div className="drp-month">
155
+ <div className="dp-header">
156
+ {gridMonth === viewMonth ? (
157
+ <button
158
+ type="button"
159
+ className="dp-nav"
160
+ onClick={prevMonth}
161
+ aria-label="Previous month"
162
+ >
163
+ <svg
164
+ width="8"
165
+ height="12"
166
+ viewBox="0 0 8 12"
167
+ fill="none"
168
+ stroke="currentColor"
169
+ strokeWidth="1.5"
170
+ strokeLinecap="round"
171
+ strokeLinejoin="round"
172
+ aria-hidden="true"
173
+ focusable="false"
174
+ >
175
+ <path d="M7 1L2 6l5 5" />
176
+ </svg>
177
+ </button>
178
+ ) : (
179
+ <span style={{ width: 28, display: 'inline-block' }} />
180
+ )}
181
+ <span className="dp-month-label" aria-live="polite">
182
+ {MONTHS[gridMonth]} {gridYear}
183
+ </span>
184
+ {gridMonth === rightMonth ? (
185
+ <button type="button" className="dp-nav" onClick={nextMonth} aria-label="Next month">
186
+ <svg
187
+ width="8"
188
+ height="12"
189
+ viewBox="0 0 8 12"
190
+ fill="none"
191
+ stroke="currentColor"
192
+ strokeWidth="1.5"
193
+ strokeLinecap="round"
194
+ strokeLinejoin="round"
195
+ aria-hidden="true"
196
+ focusable="false"
197
+ >
198
+ <path d="M1 1l5 5-5 5" />
199
+ </svg>
200
+ </button>
201
+ ) : (
202
+ <span style={{ width: 28, display: 'inline-block' }} />
203
+ )}
204
+ </div>
205
+ <table role="grid" className="dp-grid" aria-label={`${MONTHS[gridMonth]} ${gridYear}`}>
206
+ <thead>
207
+ <tr>
208
+ {DAYS.map((d) => (
209
+ <th key={d} scope="col" abbr={d}>
210
+ {d}
211
+ </th>
212
+ ))}
213
+ </tr>
214
+ </thead>
215
+ <tbody>
216
+ {Array.from({ length: 6 }, (_, row) => (
217
+ <tr key={row}>
218
+ {Array.from({ length: 7 }, (_, col) => {
219
+ const idx = row * 7 + col
220
+ const day = grid[idx]
221
+ const outside = day.getMonth() !== gridMonth
222
+ const isTodayDay = isSameDay(day, today)
223
+ const start = isStart(day)
224
+ const end = isEnd(day)
225
+ const inRange = isInRange(day)
226
+ return (
227
+ <td key={col} role="gridcell" aria-selected={start || end}>
228
+ <button
229
+ type="button"
230
+ className={[
231
+ 'dp-day',
232
+ outside && 'dp-day--outside',
233
+ isTodayDay && !start && !end && 'dp-day--today',
234
+ start && 'dp-day--start',
235
+ end && 'dp-day--end',
236
+ inRange && 'dp-day--in-range',
237
+ ]
238
+ .filter(Boolean)
239
+ .join(' ')}
240
+ aria-label={day.toLocaleDateString('en-US', {
241
+ weekday: 'long',
242
+ month: 'long',
243
+ day: 'numeric',
244
+ year: 'numeric',
245
+ })}
246
+ onClick={() => handleDayClick(day)}
247
+ onMouseEnter={() => picking && setHoverDate(day)}
248
+ onMouseLeave={() => picking && setHoverDate(null)}
249
+ >
250
+ {day.getDate()}
251
+ </button>
252
+ </td>
253
+ )
254
+ })}
255
+ </tr>
256
+ ))}
257
+ </tbody>
258
+ </table>
259
+ </div>
260
+ )
261
+ }
262
+
263
+ return (
264
+ <div className="drp-root">
265
+ <button
266
+ ref={triggerRef}
267
+ type="button"
268
+ className="btn dp-trigger"
269
+ aria-haspopup="dialog"
270
+ aria-expanded={open}
271
+ aria-controls={open ? dlgId : undefined}
272
+ disabled={disabled}
273
+ onClick={() => {
274
+ if (open) {
275
+ setOpen(false)
276
+ setPicking(null)
277
+ } else setOpen(true)
278
+ }}
279
+ >
280
+ <svg
281
+ width="14"
282
+ height="14"
283
+ viewBox="0 0 14 14"
284
+ fill="none"
285
+ stroke="currentColor"
286
+ strokeWidth="1.4"
287
+ strokeLinecap="round"
288
+ strokeLinejoin="round"
289
+ aria-hidden="true"
290
+ focusable="false"
291
+ >
292
+ <rect x="1" y="2" width="12" height="11" rx="1.5" />
293
+ <path d="M1 6h12M4 1v2M10 1v2" />
294
+ </svg>
295
+ {formatRange() ?? placeholder}
296
+ </button>
297
+ {open && (
298
+ <div
299
+ id={dlgId}
300
+ ref={dlgRef}
301
+ role="dialog"
302
+ aria-modal="true"
303
+ aria-label="Select date range"
304
+ className="drp-cal"
305
+ onKeyDown={(e) => {
306
+ if (e.key === 'Escape') {
307
+ e.preventDefault()
308
+ setOpen(false)
309
+ setPicking(null)
310
+ triggerRef.current?.focus()
311
+ }
312
+ }}
313
+ >
314
+ <div className="drp-months">
315
+ {renderCalendar(leftGrid, viewMonth, viewYear)}
316
+ {renderCalendar(rightGrid, rightMonth, rightYear)}
317
+ </div>
318
+ {picking && (
319
+ <p className="drp-hint" aria-live="polite">
320
+ Pick the end date
321
+ </p>
322
+ )}
323
+ </div>
324
+ )}
325
+ </div>
326
+ )
327
+ }