@blurrow/ui 0.1.0 → 0.2.1

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 (272) hide show
  1. package/README.md +60 -20
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +1644 -0
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +140 -0
  199. package/dist/registry/Alert.tsx +95 -0
  200. package/dist/registry/AuthorCard.tsx +67 -0
  201. package/dist/registry/Avatar.tsx +116 -0
  202. package/dist/registry/Badge.tsx +55 -0
  203. package/dist/registry/Banner.tsx +86 -0
  204. package/dist/registry/Breadcrumb.tsx +60 -0
  205. package/dist/registry/Button.tsx +361 -0
  206. package/dist/registry/ButtonGroup.tsx +82 -0
  207. package/dist/registry/Card.tsx +241 -0
  208. package/dist/registry/CardShell.tsx +78 -0
  209. package/dist/registry/Carousel.tsx +296 -0
  210. package/dist/registry/Checkbox.tsx +104 -0
  211. package/dist/registry/CloseButton.tsx +68 -0
  212. package/dist/registry/CodeBlock.tsx +202 -0
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +356 -0
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +49 -0
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +166 -0
  221. package/dist/registry/DropdownMenu.tsx +219 -0
  222. package/dist/registry/EmptyState.tsx +55 -0
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +108 -0
  228. package/dist/registry/Kbd.tsx +23 -0
  229. package/dist/registry/Modal.tsx +130 -0
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +160 -0
  233. package/dist/registry/Popover.tsx +116 -0
  234. package/dist/registry/Progress.tsx +66 -0
  235. package/dist/registry/QuoteCard.tsx +93 -0
  236. package/dist/registry/Radio.tsx +205 -0
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +101 -0
  239. package/dist/registry/ReadingProgress.tsx +51 -0
  240. package/dist/registry/ReadingTime.tsx +35 -0
  241. package/dist/registry/Select.tsx +265 -0
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +134 -0
  244. package/dist/registry/Sidebar.tsx +182 -0
  245. package/dist/registry/Skeleton.tsx +83 -0
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +106 -0
  249. package/dist/registry/Stat.tsx +82 -0
  250. package/dist/registry/StatCard.tsx +256 -0
  251. package/dist/registry/Steps.tsx +109 -0
  252. package/dist/registry/Table.tsx +123 -0
  253. package/dist/registry/TableOfContents.tsx +116 -0
  254. package/dist/registry/Tabs.tsx +136 -0
  255. package/dist/registry/TagInput.tsx +191 -0
  256. package/dist/registry/Textarea.tsx +84 -0
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +102 -0
  259. package/dist/registry/Toast.tsx +388 -0
  260. package/dist/registry/Toggle.tsx +96 -0
  261. package/dist/registry/Tooltip.tsx +160 -0
  262. package/dist/registry/styles.css +1207 -0
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/registry/useModalBehavior.ts +77 -0
  265. package/dist/styles.css +868 -62
  266. package/dist/useFloatingPanel.d.ts +24 -0
  267. package/dist/useFloatingPanel.js +89 -0
  268. package/dist/useFloatingPanel.js.map +1 -0
  269. package/dist/useModalBehavior.d.ts +15 -0
  270. package/dist/useModalBehavior.js +57 -0
  271. package/dist/useModalBehavior.js.map +1 -0
  272. package/package.json +23 -6
@@ -0,0 +1,494 @@
1
+ "use client";
2
+
3
+ import React, { useId, useMemo, useRef, useState } from "react";
4
+ import { Portal } from "./Floating";
5
+ import { FieldKey, FieldLabel, FieldMessage } from "./FieldMessage";
6
+ import { useFloatingPanel } from "./useFloatingPanel";
7
+
8
+ /* ── Date helpers ─────────────────────────────────────────────────── */
9
+ /* Everything works on local calendar days at midnight, so a date never
10
+ drifts across a time zone boundary on its way through the picker. */
11
+ export const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
12
+ const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
13
+ const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
14
+ const addMonths = (d: Date, n: number) => {
15
+ const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
16
+ const last = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
17
+ return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last));
18
+ };
19
+ const monthIndex = (d: Date) => d.getFullYear() * 12 + d.getMonth();
20
+ export const sameDay = (a?: Date | null, b?: Date | null) =>
21
+ !!a && !!b && a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
22
+ export const toISODate = (d: Date) =>
23
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
24
+
25
+ export interface DateRange {
26
+ from: Date | null;
27
+ to: Date | null;
28
+ }
29
+
30
+ /* ── Calendar ─────────────────────────────────────────────────────── */
31
+ interface CalendarProps {
32
+ value?: Date | null;
33
+ /** Shade a span of days instead of marking one (see DateRangePicker) */
34
+ range?: DateRange;
35
+ onChange?: (date: Date) => void;
36
+ /** The day under the pointer or keyboard, for previewing a range */
37
+ onDayHover?: (date: Date | null) => void;
38
+ /** The month shown first; defaults to the selected date, or today */
39
+ defaultMonth?: Date;
40
+ /** How many months sit side by side */
41
+ months?: 1 | 2;
42
+ min?: Date;
43
+ max?: Date;
44
+ /** Grey out and skip particular days, like weekends */
45
+ isDateDisabled?: (date: Date) => boolean;
46
+ /** 0 is Sunday, 1 is Monday */
47
+ weekStartsOn?: 0 | 1;
48
+ locale?: string;
49
+ /** Move focus onto the grid as soon as it mounts */
50
+ autoFocus?: boolean;
51
+ className?: string;
52
+ }
53
+
54
+ /* A month of days on the panel's face. The chosen day is a raised key in
55
+ the inverse material, today wears a small dot, and the grid follows
56
+ the ARIA date-grid pattern: arrows move by day and week, page keys by
57
+ month (shift for a year), home and end to the edges of the week. */
58
+ export function Calendar({
59
+ value,
60
+ range,
61
+ onChange,
62
+ onDayHover,
63
+ defaultMonth,
64
+ months = 1,
65
+ min,
66
+ max,
67
+ isDateDisabled,
68
+ weekStartsOn = 0,
69
+ locale = "en-US",
70
+ autoFocus = false,
71
+ className = "",
72
+ }: CalendarProps) {
73
+ const uid = useId();
74
+ const [today] = useState(() => startOfDay(new Date()));
75
+ const [focused, setFocused] = useState<Date>(() => startOfDay(value ?? range?.from ?? defaultMonth ?? today));
76
+ const [view, setView] = useState<Date>(() => startOfMonth(focused));
77
+ const [direction, setDirection] = useState<"left" | "right">("right");
78
+ // Set by keyboard moves, spent by the next day button that mounts focused
79
+ const pendingFocus = useRef(autoFocus);
80
+
81
+ // A value set from outside brings its month into view
82
+ const anchor = value ?? range?.from ?? null;
83
+ const [seen, setSeen] = useState(anchor?.getTime());
84
+ if (anchor?.getTime() !== seen) {
85
+ setSeen(anchor?.getTime());
86
+ if (anchor) {
87
+ const d = startOfDay(anchor);
88
+ setFocused(d);
89
+ const shown = monthIndex(d) - monthIndex(view);
90
+ if (shown < 0 || shown >= months) setView(startOfMonth(d));
91
+ }
92
+ }
93
+
94
+ const minDay = min && startOfDay(min);
95
+ const maxDay = max && startOfDay(max);
96
+ const disabled = (d: Date) =>
97
+ (minDay && d < minDay) || (maxDay && d > maxDay) || (isDateDisabled?.(d) ?? false);
98
+
99
+ const monthFmt = useMemo(() => new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }), [locale]);
100
+ const dayLabel = useMemo(
101
+ () => new Intl.DateTimeFormat(locale, { weekday: "long", month: "long", day: "numeric", year: "numeric" }),
102
+ [locale],
103
+ );
104
+ const weekdays = useMemo(() => {
105
+ const short = new Intl.DateTimeFormat(locale, { weekday: "short" });
106
+ const long = new Intl.DateTimeFormat(locale, { weekday: "long" });
107
+ // 2023-01-01 was a Sunday
108
+ return Array.from({ length: 7 }, (_, i) => {
109
+ const d = new Date(2023, 0, 1 + ((i + weekStartsOn) % 7));
110
+ return { short: short.format(d).slice(0, 2), long: long.format(d) };
111
+ });
112
+ }, [locale, weekStartsOn]);
113
+
114
+ /* Moves the roving focus, scrolling the view when the day is off it */
115
+ const move = (next: Date, viaKeyboard: boolean) => {
116
+ pendingFocus.current = viaKeyboard;
117
+ const offset = monthIndex(next) - monthIndex(view);
118
+ if (offset < 0) {
119
+ setDirection("left");
120
+ setView(startOfMonth(next));
121
+ } else if (offset >= months) {
122
+ setDirection("right");
123
+ setView(startOfMonth(addMonths(next, -(months - 1))));
124
+ }
125
+ setFocused(next);
126
+ if (viaKeyboard) onDayHover?.(next);
127
+ };
128
+
129
+ const flip = (n: number) => {
130
+ pendingFocus.current = false;
131
+ setDirection(n > 0 ? "right" : "left");
132
+ setView(addMonths(view, n));
133
+ setFocused(addMonths(focused, n));
134
+ };
135
+
136
+ const onKeyDown = (e: React.KeyboardEvent) => {
137
+ const dow = (focused.getDay() - weekStartsOn + 7) % 7;
138
+ const map: Record<string, () => Date> = {
139
+ ArrowLeft: () => addDays(focused, -1),
140
+ ArrowRight: () => addDays(focused, 1),
141
+ ArrowUp: () => addDays(focused, -7),
142
+ ArrowDown: () => addDays(focused, 7),
143
+ PageUp: () => addMonths(focused, e.shiftKey ? -12 : -1),
144
+ PageDown: () => addMonths(focused, e.shiftKey ? 12 : 1),
145
+ Home: () => addDays(focused, -dow),
146
+ End: () => addDays(focused, 6 - dow),
147
+ };
148
+ if (map[e.key]) {
149
+ e.preventDefault();
150
+ move(map[e.key](), true);
151
+ }
152
+ };
153
+
154
+ /* The focused day takes focus after it renders, but only when the
155
+ keyboard moved it: a click shouldn't yank focus around. */
156
+ const focusRef = (el: HTMLButtonElement | null) => {
157
+ if (!el || !pendingFocus.current) return;
158
+ pendingFocus.current = false;
159
+ el.focus({ preventScroll: true });
160
+ };
161
+
162
+ const prevDisabled = !!minDay && addDays(view, -1) < minDay;
163
+ const nextDisabled = !!maxDay && addMonths(view, months) > maxDay;
164
+
165
+ const from = range?.from ? startOfDay(range.from) : null;
166
+ const to = range?.to ? startOfDay(range.to) : null;
167
+
168
+ return (
169
+ <div
170
+ className={["flex flex-wrap justify-center gap-x-6 gap-y-4 select-none", className].join(" ")}
171
+ onKeyDown={onKeyDown}
172
+ onMouseLeave={() => onDayHover?.(null)}
173
+ >
174
+ {Array.from({ length: months }, (_, m) => {
175
+ const month = addMonths(view, m);
176
+ const first = startOfMonth(month);
177
+ const gridStart = addDays(first, -((first.getDay() - weekStartsOn + 7) % 7));
178
+ // Always six weeks, so the panel keeps its height from month to month
179
+ const weeks = Array.from({ length: 6 }, (_, w) =>
180
+ Array.from({ length: 7 }, (_, d) => addDays(gridStart, w * 7 + d)),
181
+ );
182
+ const labelId = `${uid}-month-${m}`;
183
+ return (
184
+ <div key={m} className="w-[266px]">
185
+ <div className="mb-2 flex h-7 items-center justify-between gap-2">
186
+ {m === 0 ? (
187
+ <FieldKey label="previous month" disabled={prevDisabled} onClick={() => flip(-1)}>
188
+ <path d="M15 19l-7-7 7-7" />
189
+ </FieldKey>
190
+ ) : (
191
+ <span className="w-7" />
192
+ )}
193
+ <span id={labelId} aria-live="polite" className="text-[13.5px] font-semibold text-ink tracking-[-0.01em]">
194
+ {monthFmt.format(month)}
195
+ </span>
196
+ {m === months - 1 ? (
197
+ <FieldKey label="next month" disabled={nextDisabled} onClick={() => flip(1)}>
198
+ <path d="M9 5l7 7-7 7" />
199
+ </FieldKey>
200
+ ) : (
201
+ <span className="w-7" />
202
+ )}
203
+ </div>
204
+
205
+ <div role="grid" aria-labelledby={labelId}>
206
+ <div role="row" className="grid grid-cols-7">
207
+ {weekdays.map((w) => (
208
+ <span
209
+ key={w.long}
210
+ role="columnheader"
211
+ aria-label={w.long}
212
+ className="grid h-7 place-items-center text-[11px] font-medium text-ink-faint"
213
+ >
214
+ {w.short}
215
+ </span>
216
+ ))}
217
+ </div>
218
+ <div
219
+ key={monthIndex(month)}
220
+ className={direction === "right" ? "animate-tab-slide-right" : "animate-tab-slide-left"}
221
+ >
222
+ {weeks.map((week, wi) => (
223
+ <div role="row" key={wi} className="grid grid-cols-7">
224
+ {week.map((d) => {
225
+ const inMonth = d.getMonth() === month.getMonth();
226
+ // With two months the spill-over days would show twice
227
+ if (!inMonth && months > 1) {
228
+ return <div role="gridcell" key={d.getTime()} className="h-[38px]" />;
229
+ }
230
+ const isEndpoint = range ? sameDay(d, from) || sameDay(d, to) : sameDay(d, value);
231
+ const inBand = !!(from && to && d > from && d < to);
232
+ const bandLeft = !!(from && to && sameDay(d, to) && !sameDay(from, to));
233
+ const bandRight = !!(from && to && sameDay(d, from) && !sameDay(from, to));
234
+ const isToday = sameDay(d, today);
235
+ const isOff = disabled(d);
236
+ const isFocus = sameDay(d, focused) && inMonth;
237
+ return (
238
+ <div
239
+ role="gridcell"
240
+ key={d.getTime()}
241
+ aria-selected={isEndpoint || inBand}
242
+ className="relative grid h-[38px] place-items-center"
243
+ >
244
+ {/* The span of a range, cut into the face like a well */}
245
+ {(inBand || bandLeft || bandRight) && (
246
+ <span
247
+ aria-hidden
248
+ className="absolute inset-y-[3px]"
249
+ style={{
250
+ left: bandLeft || inBand ? 0 : "50%",
251
+ right: bandRight || inBand ? 0 : "50%",
252
+ background: "var(--well)",
253
+ boxShadow: "var(--well-shadow)",
254
+ }}
255
+ />
256
+ )}
257
+ <button
258
+ type="button"
259
+ ref={isFocus ? focusRef : undefined}
260
+ tabIndex={isFocus ? 0 : -1}
261
+ disabled={isOff}
262
+ aria-label={dayLabel.format(d)}
263
+ aria-current={isToday ? "date" : undefined}
264
+ onClick={() => {
265
+ move(d, false);
266
+ if (!isOff) onChange?.(d);
267
+ }}
268
+ onMouseEnter={() => onDayHover?.(d)}
269
+ className={[
270
+ "relative grid h-9 w-9 place-items-center rounded-[10px] text-[13px] tabular-nums",
271
+ "transition-[background-color,color,box-shadow] duration-150",
272
+ "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",
273
+ "disabled:cursor-not-allowed disabled:text-ink-ghost disabled:line-through",
274
+ isEndpoint
275
+ ? "font-semibold text-ink-inverse"
276
+ : [
277
+ "cursor-pointer hover:bg-surface-active",
278
+ !inMonth ? "text-ink-ghost" : isToday ? "font-semibold text-ink-strong" : "text-ink-soft",
279
+ ].join(" "),
280
+ ].join(" ")}
281
+ style={
282
+ isEndpoint
283
+ ? {
284
+ background: "var(--face-inverse)",
285
+ boxShadow: "var(--face-inverse-shadow)",
286
+ textShadow: "var(--emboss-inverse)",
287
+ }
288
+ : undefined
289
+ }
290
+ >
291
+ {d.getDate()}
292
+ {isToday && (
293
+ <span
294
+ aria-hidden
295
+ className={[
296
+ "absolute bottom-[5px] left-1/2 h-[3px] w-[3px] -translate-x-1/2 rounded-full",
297
+ isEndpoint ? "bg-ink-inverse" : "bg-ink",
298
+ ].join(" ")}
299
+ />
300
+ )}
301
+ </button>
302
+ </div>
303
+ );
304
+ })}
305
+ </div>
306
+ ))}
307
+ </div>
308
+ </div>
309
+ </div>
310
+ );
311
+ })}
312
+ </div>
313
+ );
314
+ }
315
+
316
+ /* ── Pieces both pickers share ────────────────────────────────────── */
317
+ export const CalendarIcon = ({ className = "" }: { className?: string }) => (
318
+ <svg aria-hidden width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" className={className}>
319
+ <rect x="3" y="5" width="18" height="16" rx="2.5" />
320
+ <path strokeLinecap="round" d="M3 10h18M8 3v4M16 3v4" />
321
+ </svg>
322
+ );
323
+
324
+ export function ClearKey({ label, onClear }: { label: string; onClear: () => void }) {
325
+ return (
326
+ <button
327
+ type="button"
328
+ aria-label={label}
329
+ onClick={onClear}
330
+ className="absolute right-2.5 top-1/2 z-10 grid h-6 w-6 -translate-y-1/2 cursor-pointer place-items-center rounded-full text-ink-faint transition-colors hover:bg-surface-active hover:text-ink-soft"
331
+ >
332
+ <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
333
+ <path strokeLinecap="round" d="M6 6l12 12M18 6L6 18" />
334
+ </svg>
335
+ </button>
336
+ );
337
+ }
338
+
339
+ /* ── DatePicker ───────────────────────────────────────────────────── */
340
+ interface DatePickerProps {
341
+ value?: Date | null;
342
+ defaultValue?: Date | null;
343
+ onChange?: (date: Date | null) => void;
344
+ label?: string;
345
+ hint?: string;
346
+ error?: string;
347
+ placeholder?: string;
348
+ min?: Date;
349
+ max?: Date;
350
+ isDateDisabled?: (date: Date) => boolean;
351
+ weekStartsOn?: 0 | 1;
352
+ locale?: string;
353
+ /** How the chosen date reads in the field */
354
+ format?: Intl.DateTimeFormatOptions;
355
+ /** Submit the date with a form, as yyyy-mm-dd */
356
+ name?: string;
357
+ /** Show a small key in the field that empties it */
358
+ clearable?: boolean;
359
+ disabled?: boolean;
360
+ fullWidth?: boolean;
361
+ className?: string;
362
+ }
363
+
364
+ const DEFAULT_FORMAT: Intl.DateTimeFormatOptions = { month: "short", day: "numeric", year: "numeric" };
365
+
366
+ /* The closed picker is a sunken field like Select; the calendar opens
367
+ under it on a floating panel. Picking a day closes it and hands
368
+ focus back to the field. */
369
+ export function DatePicker({
370
+ value,
371
+ defaultValue = null,
372
+ onChange,
373
+ label,
374
+ hint,
375
+ error,
376
+ placeholder = "pick a date",
377
+ min,
378
+ max,
379
+ isDateDisabled,
380
+ weekStartsOn,
381
+ locale = "en-US",
382
+ format = DEFAULT_FORMAT,
383
+ name,
384
+ clearable = false,
385
+ disabled = false,
386
+ fullWidth = false,
387
+ className = "",
388
+ }: DatePickerProps) {
389
+ const uid = useId();
390
+ const ref = useRef<HTMLDivElement>(null);
391
+ const buttonRef = useRef<HTMLButtonElement>(null);
392
+ const [internal, setInternal] = useState<Date | null>(defaultValue);
393
+ const selected = value !== undefined ? value : internal;
394
+ const { open, mounted, toggle, show, close, setFloating, panelStyle } = useFloatingPanel(ref, {
395
+ onEscape: () => buttonRef.current?.focus(),
396
+ });
397
+ const messageId = error || hint ? `${uid}-message` : undefined;
398
+ const text = selected ? new Intl.DateTimeFormat(locale, format).format(selected) : null;
399
+
400
+ const set = (d: Date | null) => {
401
+ setInternal(d);
402
+ onChange?.(d);
403
+ };
404
+
405
+ return (
406
+ <div className={["group/field flex flex-col gap-1.5", fullWidth ? "w-full" : ""].join(" ")}>
407
+ {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
408
+ <div ref={ref} className={["relative", fullWidth ? "w-full" : "inline-block w-56", className].join(" ")}>
409
+ {/* The field's error styling keys off aria-invalid, as Input's does */}
410
+ {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */}
411
+ <button
412
+ ref={buttonRef}
413
+ type="button"
414
+ disabled={disabled}
415
+ onClick={toggle}
416
+ onKeyDown={(e) => {
417
+ if (!open && e.key === "ArrowDown") {
418
+ e.preventDefault();
419
+ show();
420
+ }
421
+ }}
422
+ aria-haspopup="dialog"
423
+ aria-expanded={open}
424
+ aria-controls={open ? `${uid}-panel` : undefined}
425
+ aria-labelledby={label ? `${uid}-label ${uid}-value` : undefined}
426
+ aria-invalid={error ? true : undefined}
427
+ aria-describedby={messageId}
428
+ data-open={open || undefined}
429
+ className={[
430
+ "field group/date relative isolate flex w-full cursor-pointer items-center gap-2.5 rounded-xl py-3 pl-3.5 text-left text-[13.5px]",
431
+ clearable && selected ? "pr-10" : "pr-4",
432
+ "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
433
+ ].join(" ")}
434
+ >
435
+ <CalendarIcon
436
+ className={[
437
+ "shrink-0 transition-[color,transform] duration-300 ease-[var(--ease-spring)]",
438
+ open ? "scale-110 text-ink-soft" : "text-ink-faint group-hover/date:text-ink-body",
439
+ ].join(" ")}
440
+ />
441
+ <span
442
+ id={`${uid}-value`}
443
+ className={["truncate tabular-nums", text ? "font-medium text-ink" : "text-ink-faint"].join(" ")}
444
+ >
445
+ {text ?? placeholder}
446
+ </span>
447
+ </button>
448
+
449
+ {clearable && selected && !disabled && (
450
+ <ClearKey
451
+ label="clear date"
452
+ onClear={() => {
453
+ set(null);
454
+ buttonRef.current?.focus();
455
+ }}
456
+ />
457
+ )}
458
+
459
+ {name && <input type="hidden" name={name} value={selected ? toISODate(selected) : ""} />}
460
+ </div>
461
+
462
+ {mounted && (
463
+ <Portal>
464
+ <div
465
+ ref={setFloating}
466
+ id={`${uid}-panel`}
467
+ role="dialog"
468
+ aria-label={label ? `choose ${label}` : "choose a date"}
469
+ className="rounded-[16px] p-1.5"
470
+ style={panelStyle}
471
+ >
472
+ <div className="rounded-[11px] bg-panel-face p-2.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
473
+ <Calendar
474
+ value={selected}
475
+ onChange={(d) => {
476
+ set(d);
477
+ close();
478
+ buttonRef.current?.focus();
479
+ }}
480
+ min={min}
481
+ max={max}
482
+ isDateDisabled={isDateDisabled}
483
+ weekStartsOn={weekStartsOn}
484
+ locale={locale}
485
+ autoFocus
486
+ />
487
+ </div>
488
+ </div>
489
+ </Portal>
490
+ )}
491
+ <FieldMessage id={messageId} error={error} hint={hint} />
492
+ </div>
493
+ );
494
+ }