@c9up/nebula 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 (327) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +256 -0
  3. package/dist/adapters/css.d.ts +33 -0
  4. package/dist/adapters/css.js +67 -0
  5. package/dist/adapters/index.d.ts +16 -0
  6. package/dist/adapters/index.js +27 -0
  7. package/dist/adapters/tailwind.d.ts +25 -0
  8. package/dist/adapters/tailwind.js +122 -0
  9. package/dist/adapters/types.d.ts +49 -0
  10. package/dist/adapters/types.js +21 -0
  11. package/dist/adapters/unocss.d.ts +17 -0
  12. package/dist/adapters/unocss.js +107 -0
  13. package/dist/atoms/AspectRatio.d.ts +17 -0
  14. package/dist/atoms/AspectRatio.js +19 -0
  15. package/dist/atoms/Avatar.d.ts +23 -0
  16. package/dist/atoms/Avatar.js +46 -0
  17. package/dist/atoms/Badge.d.ts +25 -0
  18. package/dist/atoms/Badge.js +28 -0
  19. package/dist/atoms/Button.d.ts +58 -0
  20. package/dist/atoms/Button.js +67 -0
  21. package/dist/atoms/Checkbox.d.ts +41 -0
  22. package/dist/atoms/Checkbox.js +47 -0
  23. package/dist/atoms/Input.d.ts +36 -0
  24. package/dist/atoms/Input.js +50 -0
  25. package/dist/atoms/Kbd.d.ts +14 -0
  26. package/dist/atoms/Kbd.js +17 -0
  27. package/dist/atoms/Label.d.ts +24 -0
  28. package/dist/atoms/Label.js +26 -0
  29. package/dist/atoms/Marker.d.ts +42 -0
  30. package/dist/atoms/Marker.js +55 -0
  31. package/dist/atoms/NativeSelect.d.ts +43 -0
  32. package/dist/atoms/NativeSelect.js +81 -0
  33. package/dist/atoms/Progress.d.ts +21 -0
  34. package/dist/atoms/Progress.js +48 -0
  35. package/dist/atoms/ScrollArea.d.ts +28 -0
  36. package/dist/atoms/ScrollArea.js +37 -0
  37. package/dist/atoms/Separator.d.ts +16 -0
  38. package/dist/atoms/Separator.js +21 -0
  39. package/dist/atoms/Skeleton.d.ts +13 -0
  40. package/dist/atoms/Skeleton.js +18 -0
  41. package/dist/atoms/Slider.d.ts +30 -0
  42. package/dist/atoms/Slider.js +40 -0
  43. package/dist/atoms/Spinner.d.ts +17 -0
  44. package/dist/atoms/Spinner.js +20 -0
  45. package/dist/atoms/Switch.d.ts +24 -0
  46. package/dist/atoms/Switch.js +41 -0
  47. package/dist/atoms/Textarea.d.ts +28 -0
  48. package/dist/atoms/Textarea.js +36 -0
  49. package/dist/atoms/Toggle.d.ts +38 -0
  50. package/dist/atoms/Toggle.js +45 -0
  51. package/dist/atoms/index.d.ts +32 -0
  52. package/dist/atoms/index.js +32 -0
  53. package/dist/cli/add.d.ts +44 -0
  54. package/dist/cli/add.js +99 -0
  55. package/dist/cli/index.d.ts +10 -0
  56. package/dist/cli/index.js +10 -0
  57. package/dist/cli/registry.d.ts +31 -0
  58. package/dist/cli/registry.js +83 -0
  59. package/dist/config.d.ts +81 -0
  60. package/dist/config.js +43 -0
  61. package/dist/configure.d.ts +34 -0
  62. package/dist/configure.js +85 -0
  63. package/dist/index.d.ts +41 -0
  64. package/dist/index.js +41 -0
  65. package/dist/lib/children.d.ts +28 -0
  66. package/dist/lib/children.js +27 -0
  67. package/dist/lib/cn.d.ts +13 -0
  68. package/dist/lib/cn.js +13 -0
  69. package/dist/lib/cva.d.ts +83 -0
  70. package/dist/lib/cva.js +178 -0
  71. package/dist/lib/icons.d.ts +44 -0
  72. package/dist/lib/icons.js +79 -0
  73. package/dist/lib/id.d.ts +31 -0
  74. package/dist/lib/id.js +41 -0
  75. package/dist/lib/index.d.ts +12 -0
  76. package/dist/lib/index.js +12 -0
  77. package/dist/lib/motion.d.ts +31 -0
  78. package/dist/lib/motion.js +39 -0
  79. package/dist/lib/props.d.ts +48 -0
  80. package/dist/lib/props.js +59 -0
  81. package/dist/lib/styled.d.ts +31 -0
  82. package/dist/lib/styled.js +39 -0
  83. package/dist/molecules/Accordion.d.ts +35 -0
  84. package/dist/molecules/Accordion.js +117 -0
  85. package/dist/molecules/Alert.d.ts +30 -0
  86. package/dist/molecules/Alert.js +35 -0
  87. package/dist/molecules/Attachment.d.ts +39 -0
  88. package/dist/molecules/Attachment.js +103 -0
  89. package/dist/molecules/Breadcrumb.d.ts +29 -0
  90. package/dist/molecules/Breadcrumb.js +73 -0
  91. package/dist/molecules/Bubble.d.ts +56 -0
  92. package/dist/molecules/Bubble.js +92 -0
  93. package/dist/molecules/ButtonGroup.d.ts +23 -0
  94. package/dist/molecules/ButtonGroup.js +30 -0
  95. package/dist/molecules/Card.d.ts +17 -0
  96. package/dist/molecules/Card.js +17 -0
  97. package/dist/molecules/Collapsible.d.ts +40 -0
  98. package/dist/molecules/Collapsible.js +71 -0
  99. package/dist/molecules/Empty.d.ts +16 -0
  100. package/dist/molecules/Empty.js +16 -0
  101. package/dist/molecules/Field.d.ts +54 -0
  102. package/dist/molecules/Field.js +82 -0
  103. package/dist/molecules/InputGroup.d.ts +33 -0
  104. package/dist/molecules/InputGroup.js +35 -0
  105. package/dist/molecules/InputOTP.d.ts +33 -0
  106. package/dist/molecules/InputOTP.js +140 -0
  107. package/dist/molecules/Item.d.ts +41 -0
  108. package/dist/molecules/Item.js +48 -0
  109. package/dist/molecules/Message.d.ts +43 -0
  110. package/dist/molecules/Message.js +82 -0
  111. package/dist/molecules/Pagination.d.ts +41 -0
  112. package/dist/molecules/Pagination.js +144 -0
  113. package/dist/molecules/RadioGroup.d.ts +32 -0
  114. package/dist/molecules/RadioGroup.js +65 -0
  115. package/dist/molecules/Resizable.d.ts +37 -0
  116. package/dist/molecules/Resizable.js +136 -0
  117. package/dist/molecules/Table.d.ts +37 -0
  118. package/dist/molecules/Table.js +78 -0
  119. package/dist/molecules/Tabs.d.ts +37 -0
  120. package/dist/molecules/Tabs.js +109 -0
  121. package/dist/molecules/ToggleGroup.d.ts +37 -0
  122. package/dist/molecules/ToggleGroup.js +68 -0
  123. package/dist/molecules/Typography.d.ts +26 -0
  124. package/dist/molecules/Typography.js +67 -0
  125. package/dist/molecules/index.d.ts +37 -0
  126. package/dist/molecules/index.js +37 -0
  127. package/dist/organisms/AlertDialog.d.ts +40 -0
  128. package/dist/organisms/AlertDialog.js +104 -0
  129. package/dist/organisms/Calendar.d.ts +96 -0
  130. package/dist/organisms/Calendar.js +353 -0
  131. package/dist/organisms/Carousel.d.ts +29 -0
  132. package/dist/organisms/Carousel.js +128 -0
  133. package/dist/organisms/Chart.d.ts +55 -0
  134. package/dist/organisms/Chart.js +189 -0
  135. package/dist/organisms/Combobox.d.ts +41 -0
  136. package/dist/organisms/Combobox.js +102 -0
  137. package/dist/organisms/Command.d.ts +46 -0
  138. package/dist/organisms/Command.js +171 -0
  139. package/dist/organisms/CommandDialog.d.ts +42 -0
  140. package/dist/organisms/CommandDialog.js +116 -0
  141. package/dist/organisms/ContextMenu.d.ts +29 -0
  142. package/dist/organisms/ContextMenu.js +102 -0
  143. package/dist/organisms/DataTable.d.ts +50 -0
  144. package/dist/organisms/DataTable.js +257 -0
  145. package/dist/organisms/DatePicker.d.ts +34 -0
  146. package/dist/organisms/DatePicker.js +115 -0
  147. package/dist/organisms/DateRangePicker.d.ts +34 -0
  148. package/dist/organisms/DateRangePicker.js +122 -0
  149. package/dist/organisms/Dialog.d.ts +50 -0
  150. package/dist/organisms/Dialog.js +116 -0
  151. package/dist/organisms/Drawer.d.ts +40 -0
  152. package/dist/organisms/Drawer.js +131 -0
  153. package/dist/organisms/DropdownMenu.d.ts +30 -0
  154. package/dist/organisms/DropdownMenu.js +91 -0
  155. package/dist/organisms/Form.d.ts +114 -0
  156. package/dist/organisms/Form.js +151 -0
  157. package/dist/organisms/HoverCard.d.ts +30 -0
  158. package/dist/organisms/HoverCard.js +68 -0
  159. package/dist/organisms/Menubar.d.ts +28 -0
  160. package/dist/organisms/Menubar.js +147 -0
  161. package/dist/organisms/MessageScroller.d.ts +36 -0
  162. package/dist/organisms/MessageScroller.js +124 -0
  163. package/dist/organisms/NavigationMenu.d.ts +41 -0
  164. package/dist/organisms/NavigationMenu.js +139 -0
  165. package/dist/organisms/Popover.d.ts +33 -0
  166. package/dist/organisms/Popover.js +58 -0
  167. package/dist/organisms/Questionnaire.d.ts +74 -0
  168. package/dist/organisms/Questionnaire.js +235 -0
  169. package/dist/organisms/Select.d.ts +47 -0
  170. package/dist/organisms/Select.js +250 -0
  171. package/dist/organisms/Sheet.d.ts +34 -0
  172. package/dist/organisms/Sheet.js +107 -0
  173. package/dist/organisms/Sidebar.d.ts +100 -0
  174. package/dist/organisms/Sidebar.js +228 -0
  175. package/dist/organisms/Toaster.d.ts +67 -0
  176. package/dist/organisms/Toaster.js +189 -0
  177. package/dist/organisms/Tooltip.d.ts +39 -0
  178. package/dist/organisms/Tooltip.js +94 -0
  179. package/dist/organisms/index.d.ts +40 -0
  180. package/dist/organisms/index.js +40 -0
  181. package/dist/primitives/controllable.d.ts +38 -0
  182. package/dist/primitives/controllable.js +43 -0
  183. package/dist/primitives/dismissable.d.ts +53 -0
  184. package/dist/primitives/dismissable.js +158 -0
  185. package/dist/primitives/floating.d.ts +116 -0
  186. package/dist/primitives/floating.js +289 -0
  187. package/dist/primitives/floatingSurface.d.ts +56 -0
  188. package/dist/primitives/floatingSurface.js +142 -0
  189. package/dist/primitives/focusTrap.d.ts +47 -0
  190. package/dist/primitives/focusTrap.js +101 -0
  191. package/dist/primitives/focusable.d.ts +53 -0
  192. package/dist/primitives/focusable.js +109 -0
  193. package/dist/primitives/index.d.ts +27 -0
  194. package/dist/primitives/index.js +27 -0
  195. package/dist/primitives/menu.d.ts +102 -0
  196. package/dist/primitives/menu.js +356 -0
  197. package/dist/primitives/modalSurface.d.ts +54 -0
  198. package/dist/primitives/modalSurface.js +146 -0
  199. package/dist/primitives/portal.d.ts +34 -0
  200. package/dist/primitives/portal.js +42 -0
  201. package/dist/primitives/presence.d.ts +45 -0
  202. package/dist/primitives/presence.js +152 -0
  203. package/dist/primitives/rovingFocus.d.ts +47 -0
  204. package/dist/primitives/rovingFocus.js +176 -0
  205. package/dist/primitives/scrollLock.d.ts +21 -0
  206. package/dist/primitives/scrollLock.js +75 -0
  207. package/dist/primitives/typeahead.d.ts +39 -0
  208. package/dist/primitives/typeahead.js +123 -0
  209. package/dist/templates/AppShell.d.ts +30 -0
  210. package/dist/templates/AppShell.js +42 -0
  211. package/dist/templates/AuthLayout.d.ts +23 -0
  212. package/dist/templates/AuthLayout.js +42 -0
  213. package/dist/templates/SettingsLayout.d.ts +27 -0
  214. package/dist/templates/SettingsLayout.js +42 -0
  215. package/dist/templates/index.d.ts +16 -0
  216. package/dist/templates/index.js +16 -0
  217. package/nebula.css +2 -0
  218. package/package.json +92 -0
  219. package/registry.json +1107 -0
  220. package/src/adapters/css.ts +75 -0
  221. package/src/adapters/index.ts +40 -0
  222. package/src/adapters/tailwind.ts +134 -0
  223. package/src/adapters/types.ts +53 -0
  224. package/src/adapters/unocss.ts +116 -0
  225. package/src/atoms/AspectRatio.ts +28 -0
  226. package/src/atoms/Avatar.ts +67 -0
  227. package/src/atoms/Badge.ts +46 -0
  228. package/src/atoms/Button.ts +96 -0
  229. package/src/atoms/Checkbox.ts +76 -0
  230. package/src/atoms/Input.ts +76 -0
  231. package/src/atoms/Kbd.ts +28 -0
  232. package/src/atoms/Label.ts +38 -0
  233. package/src/atoms/Marker.ts +79 -0
  234. package/src/atoms/NativeSelect.ts +115 -0
  235. package/src/atoms/Progress.ts +64 -0
  236. package/src/atoms/ScrollArea.ts +53 -0
  237. package/src/atoms/Separator.ts +36 -0
  238. package/src/atoms/Skeleton.ts +24 -0
  239. package/src/atoms/Slider.ts +65 -0
  240. package/src/atoms/Spinner.ts +28 -0
  241. package/src/atoms/Switch.ts +56 -0
  242. package/src/atoms/Textarea.ts +58 -0
  243. package/src/atoms/Toggle.ts +67 -0
  244. package/src/atoms/index.ts +62 -0
  245. package/src/cli/add.ts +126 -0
  246. package/src/cli/index.ts +22 -0
  247. package/src/cli/registry.ts +116 -0
  248. package/src/config.ts +102 -0
  249. package/src/configure.ts +127 -0
  250. package/src/index.ts +53 -0
  251. package/src/lib/children.ts +43 -0
  252. package/src/lib/cn.ts +14 -0
  253. package/src/lib/cva.ts +249 -0
  254. package/src/lib/icons.ts +138 -0
  255. package/src/lib/id.ts +44 -0
  256. package/src/lib/index.ts +21 -0
  257. package/src/lib/motion.ts +48 -0
  258. package/src/lib/props.ts +83 -0
  259. package/src/lib/styled.ts +60 -0
  260. package/src/molecules/Accordion.ts +161 -0
  261. package/src/molecules/Alert.ts +58 -0
  262. package/src/molecules/Attachment.ts +132 -0
  263. package/src/molecules/Breadcrumb.ts +107 -0
  264. package/src/molecules/Bubble.ts +134 -0
  265. package/src/molecules/ButtonGroup.ts +55 -0
  266. package/src/molecules/Card.ts +42 -0
  267. package/src/molecules/Collapsible.ts +89 -0
  268. package/src/molecules/Empty.ts +43 -0
  269. package/src/molecules/Field.ts +131 -0
  270. package/src/molecules/InputGroup.ts +62 -0
  271. package/src/molecules/InputOTP.ts +166 -0
  272. package/src/molecules/Item.ts +89 -0
  273. package/src/molecules/Message.ts +127 -0
  274. package/src/molecules/Pagination.ts +197 -0
  275. package/src/molecules/RadioGroup.ts +104 -0
  276. package/src/molecules/Resizable.ts +166 -0
  277. package/src/molecules/Table.ts +118 -0
  278. package/src/molecules/Tabs.ts +148 -0
  279. package/src/molecules/ToggleGroup.ts +109 -0
  280. package/src/molecules/Typography.ts +94 -0
  281. package/src/molecules/index.ts +144 -0
  282. package/src/organisms/AlertDialog.ts +133 -0
  283. package/src/organisms/Calendar.ts +470 -0
  284. package/src/organisms/Carousel.ts +161 -0
  285. package/src/organisms/Chart.ts +257 -0
  286. package/src/organisms/Combobox.ts +140 -0
  287. package/src/organisms/Command.ts +211 -0
  288. package/src/organisms/CommandDialog.ts +149 -0
  289. package/src/organisms/ContextMenu.ts +125 -0
  290. package/src/organisms/DataTable.ts +328 -0
  291. package/src/organisms/DatePicker.ts +141 -0
  292. package/src/organisms/DateRangePicker.ts +149 -0
  293. package/src/organisms/Dialog.ts +162 -0
  294. package/src/organisms/Drawer.ts +167 -0
  295. package/src/organisms/DropdownMenu.ts +112 -0
  296. package/src/organisms/Form.ts +217 -0
  297. package/src/organisms/HoverCard.ts +92 -0
  298. package/src/organisms/Menubar.ts +167 -0
  299. package/src/organisms/MessageScroller.ts +145 -0
  300. package/src/organisms/NavigationMenu.ts +184 -0
  301. package/src/organisms/Popover.ts +84 -0
  302. package/src/organisms/Questionnaire.ts +322 -0
  303. package/src/organisms/Select.ts +286 -0
  304. package/src/organisms/Sheet.ts +144 -0
  305. package/src/organisms/Sidebar.ts +336 -0
  306. package/src/organisms/Toaster.ts +246 -0
  307. package/src/organisms/Tooltip.ts +124 -0
  308. package/src/organisms/index.ts +134 -0
  309. package/src/primitives/controllable.ts +64 -0
  310. package/src/primitives/dismissable.ts +195 -0
  311. package/src/primitives/floating.ts +453 -0
  312. package/src/primitives/floatingSurface.ts +209 -0
  313. package/src/primitives/focusTrap.ts +132 -0
  314. package/src/primitives/focusable.ts +108 -0
  315. package/src/primitives/index.ts +88 -0
  316. package/src/primitives/menu.ts +491 -0
  317. package/src/primitives/modalSurface.ts +199 -0
  318. package/src/primitives/portal.ts +61 -0
  319. package/src/primitives/presence.ts +185 -0
  320. package/src/primitives/rovingFocus.ts +212 -0
  321. package/src/primitives/scrollLock.ts +79 -0
  322. package/src/primitives/typeahead.ts +156 -0
  323. package/src/templates/AppShell.ts +56 -0
  324. package/src/templates/AuthLayout.ts +66 -0
  325. package/src/templates/SettingsLayout.ts +63 -0
  326. package/src/templates/index.ts +21 -0
  327. package/theme.css +151 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Calendar — a month grid for picking a date.
3
+ *
4
+ * shadcn wraps `react-day-picker`, which brings `date-fns` with it. nebula
5
+ * uses `Date` and `Intl`, which cover everything this needs: month lengths and
6
+ * leap years come from `Date`, and weekday and month names come from
7
+ * `Intl.DateTimeFormat` in the user's locale for free — which is more than a
8
+ * hardcoded English array would give.
9
+ *
10
+ * Dates are handled at local midnight throughout. A calendar cell means "this
11
+ * day", not "this instant", and mixing the two is how a date picker ends up
12
+ * off by one for users west of UTC: `new Date("2026-03-14")` parses as UTC
13
+ * midnight, which is the 13th in New York.
14
+ *
15
+ * The keyboard model is the WAI-ARIA grid pattern, and it is what makes the
16
+ * control usable at all without a pointer: arrows move a day, PageUp/PageDown
17
+ * move a month, Home and End jump to the ends of the week. Only one cell is
18
+ * ever tabbable — the focused day — so Tab leaves the grid instead of walking
19
+ * through thirty-one buttons.
20
+ */
21
+
22
+ import { component, html, signal } from "@c9up/aurora";
23
+ import { buttonVariants } from "../atoms/Button.js";
24
+ import type { Child } from "../lib/children.js";
25
+ import { cn } from "../lib/cn.js";
26
+ import { ChevronLeftIcon, ChevronRightIcon } from "../lib/icons.js";
27
+ import { uid } from "../lib/id.js";
28
+ import { type Reactive, read } from "../lib/props.js";
29
+
30
+ /**
31
+ * A span of days. `to` is absent while the second end is still being picked —
32
+ * that half-open state is what the hover preview renders against, so it is
33
+ * part of the type rather than something the component hides internally.
34
+ */
35
+ export interface DateRange {
36
+ readonly from: Date;
37
+ readonly to?: Date;
38
+ }
39
+
40
+ export interface CalendarProps {
41
+ /**
42
+ * `"range"` picks a span in two clicks. Default `"single"`.
43
+ *
44
+ * The two modes carry their value in separate props rather than one union.
45
+ * A union would make every read of `value` a narrowing exercise for the
46
+ * caller as much as for this file, and the two `onChange` shapes genuinely
47
+ * differ — a range handler that receives a bare `Date` has no way to say
48
+ * which end moved.
49
+ */
50
+ mode?: "single" | "range";
51
+ /** Selected day, in `"single"` mode. Local midnight. */
52
+ value?: Reactive<Date | undefined>;
53
+ defaultValue?: Date;
54
+ /** Selected span, in `"range"` mode. */
55
+ range?: Reactive<DateRange | undefined>;
56
+ defaultRange?: DateRange;
57
+ /** Month shown at first render. Defaults to the selection, then today. */
58
+ defaultMonth?: Date;
59
+ min?: Date;
60
+ max?: Date;
61
+ /** Rule out individual days — weekends, holidays, taken slots. */
62
+ disabled?: (date: Date) => boolean;
63
+ /** `0` Sunday, `1` Monday. Defaults to the locale's own first day. */
64
+ weekStartsOn?: number;
65
+ locale?: string;
66
+ class?: Reactive<string>;
67
+ /** `"single"` mode. */
68
+ onValueChange?: (date: Date) => void;
69
+ /** `"range"` mode. Fires on both clicks — `to` is absent after the first. */
70
+ onRangeChange?: (range: DateRange) => void;
71
+ }
72
+
73
+ // ─── date helpers ────────────────────────────────────────────────────
74
+
75
+ /** Local midnight of the given day — the canonical form for a calendar date. */
76
+ export function startOfDay(date: Date): Date {
77
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate());
78
+ }
79
+
80
+ export function isSameDay(a: Date, b: Date): boolean {
81
+ return (
82
+ a.getFullYear() === b.getFullYear() &&
83
+ a.getMonth() === b.getMonth() &&
84
+ a.getDate() === b.getDate()
85
+ );
86
+ }
87
+
88
+ export function addDays(date: Date, days: number): Date {
89
+ // Day-of-month arithmetic through the Date constructor, which normalises
90
+ // overflow — the 32nd of March becomes the 1st of April, leap years and
91
+ // month lengths included. Adding milliseconds would not: a day is not
92
+ // always 86 400 000 ms across a daylight-saving boundary.
93
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
94
+ }
95
+
96
+ export function addMonths(date: Date, months: number): Date {
97
+ const target = new Date(date.getFullYear(), date.getMonth() + months, 1);
98
+ // Clamp the day: 31 January plus one month is 28 February, not 3 March.
99
+ const lastDay = new Date(
100
+ target.getFullYear(),
101
+ target.getMonth() + 1,
102
+ 0,
103
+ ).getDate();
104
+ return new Date(
105
+ target.getFullYear(),
106
+ target.getMonth(),
107
+ Math.min(date.getDate(), lastDay),
108
+ );
109
+ }
110
+
111
+ /**
112
+ * The six-week grid covering a month.
113
+ *
114
+ * Always six weeks, always 42 cells, even when five would do. A grid that
115
+ * changes height between months makes the whole popover jump as you page
116
+ * through it.
117
+ */
118
+ export function monthGrid(month: Date, weekStartsOn: number): Date[] {
119
+ const first = new Date(month.getFullYear(), month.getMonth(), 1);
120
+ const lead = (first.getDay() - weekStartsOn + 7) % 7;
121
+ const start = addDays(first, -lead);
122
+ return Array.from({ length: 42 }, (_unused, index) => addDays(start, index));
123
+ }
124
+
125
+ /**
126
+ * `Intl.Locale.getWeekInfo` — present at runtime in current engines, absent
127
+ * from the TypeScript DOM lib. Declared here and reached through a guard, so
128
+ * the call is checked rather than asserted.
129
+ */
130
+ interface WithWeekInfo {
131
+ getWeekInfo(): { firstDay: number };
132
+ }
133
+
134
+ function hasWeekInfo(value: object): value is WithWeekInfo {
135
+ return "getWeekInfo" in value && typeof value.getWeekInfo === "function";
136
+ }
137
+
138
+ /** The locale's first day of the week, or Monday where it cannot be read. */
139
+ function localeWeekStart(locale: string | undefined): number {
140
+ const resolved = new Intl.Locale(locale ?? navigator.language);
141
+ if (!hasWeekInfo(resolved)) return 1;
142
+ // `firstDay` is 1–7 with 7 for Sunday; the Date API uses 0–6 with 0 Sunday.
143
+ return resolved.getWeekInfo().firstDay % 7;
144
+ }
145
+
146
+ // ─── range logic (pure) ──────────────────────────────────────────────
147
+
148
+ /** Where a day sits within a span. `"single"` is a span of one day. */
149
+ export type RangePosition = "single" | "start" | "end" | "middle" | "none";
150
+
151
+ /**
152
+ * Place a day relative to a span.
153
+ *
154
+ * `end` is passed in rather than read off the range because it may be the
155
+ * *preview* end — wherever the pointer is, before the second click. Keeping
156
+ * that decision at the call site is what lets this stay a pure function over
157
+ * three dates, which is the part worth testing exhaustively.
158
+ */
159
+ export function rangePosition(
160
+ date: Date,
161
+ range: DateRange | undefined,
162
+ end: Date | undefined,
163
+ ): RangePosition {
164
+ if (range === undefined) return "none";
165
+ if (end === undefined) {
166
+ return isSameDay(date, range.from) ? "single" : "none";
167
+ }
168
+
169
+ // Ordered, because the preview end can sit before the fixed start while the
170
+ // pointer sweeps backwards.
171
+ const [lo, hi] = range.from <= end ? [range.from, end] : [end, range.from];
172
+ // A one-day span is both ends at once, and squaring either side of it would
173
+ // leave a lone cell with a flat edge against nothing.
174
+ if (isSameDay(lo, hi)) return isSameDay(date, lo) ? "single" : "none";
175
+ if (isSameDay(date, lo)) return "start";
176
+ if (isSameDay(date, hi)) return "end";
177
+ return date > lo && date < hi ? "middle" : "none";
178
+ }
179
+
180
+ /**
181
+ * The span after clicking `date`.
182
+ *
183
+ * A click before the fixed start restarts the span rather than producing an
184
+ * inverted one. Silently swapping the ends would be the other option, and it
185
+ * is worse: the user who clicked the 3rd after the 10th almost always meant to
186
+ * start again, not to select the week between them.
187
+ */
188
+ export function nextRange(
189
+ current: DateRange | undefined,
190
+ date: Date,
191
+ ): DateRange {
192
+ if (
193
+ current === undefined ||
194
+ current.to !== undefined ||
195
+ date < current.from
196
+ ) {
197
+ return { from: date };
198
+ }
199
+ return { from: current.from, to: date };
200
+ }
201
+
202
+ // ─── component ───────────────────────────────────────────────────────
203
+
204
+ export const Calendar = component<CalendarProps>((props) => {
205
+ const gridId = uid("calendar-grid");
206
+ const labelId = uid("calendar-label");
207
+ const locale = props.locale;
208
+ const weekStartsOn = props.weekStartsOn ?? safeWeekStart(locale);
209
+
210
+ const isRange = props.mode === "range";
211
+ const today = startOfDay(new Date());
212
+ const initial =
213
+ props.defaultValue ??
214
+ props.defaultRange?.from ??
215
+ props.defaultMonth ??
216
+ today;
217
+ const month = signal(new Date(initial.getFullYear(), initial.getMonth(), 1));
218
+ /** The day the keyboard is on. Only this cell is tabbable. */
219
+ const cursor = signal(startOfDay(initial));
220
+
221
+ const selected = (): Date | undefined =>
222
+ read(props.value) ?? props.defaultValue;
223
+
224
+ /** The span being built, when the caller does not control it. */
225
+ const draft = signal<DateRange | undefined>(props.defaultRange);
226
+ /** The day under the pointer, for the preview half of an open range. */
227
+ const hovered = signal<Date | undefined>(undefined);
228
+
229
+ const activeRange = (): DateRange | undefined => read(props.range) ?? draft();
230
+
231
+ /**
232
+ * The far end of the span as currently drawn.
233
+ *
234
+ * While only one end is fixed, that is wherever the pointer is — which is
235
+ * what makes the range visibly follow the cursor before the second click.
236
+ * Keyboard users get no preview, and that is fine: for them the span
237
+ * appears on Enter, and a preview tied to the roving cursor would announce
238
+ * a selection that has not happened.
239
+ */
240
+ function rangeEnd(): Date | undefined {
241
+ const current = activeRange();
242
+ if (current === undefined) return undefined;
243
+ return current.to ?? hovered();
244
+ }
245
+
246
+ type RangePosition = "single" | "start" | "end" | "middle" | "none";
247
+
248
+ function rangePositionOf(date: Date): RangePosition {
249
+ const current = activeRange();
250
+ if (current === undefined) return "none";
251
+
252
+ const end = rangeEnd();
253
+ if (end === undefined) {
254
+ return isSameDay(date, current.from) ? "single" : "none";
255
+ }
256
+
257
+ // Ordered, because the preview end can sit before the fixed start while
258
+ // the pointer sweeps backwards.
259
+ const [lo, hi] =
260
+ current.from <= end ? [current.from, end] : [end, current.from];
261
+ // A one-day span is both ends at once, and squaring either side of it
262
+ // would leave a lone cell with a flat edge against nothing.
263
+ if (isSameDay(lo, hi)) return isSameDay(date, lo) ? "single" : "none";
264
+ if (isSameDay(date, lo)) return "start";
265
+ if (isSameDay(date, hi)) return "end";
266
+ return date > lo && date < hi ? "middle" : "none";
267
+ }
268
+
269
+ const monthLabel = new Intl.DateTimeFormat(locale, {
270
+ month: "long",
271
+ year: "numeric",
272
+ });
273
+ const weekdayLabel = new Intl.DateTimeFormat(locale, { weekday: "short" });
274
+ const fullDate = new Intl.DateTimeFormat(locale, { dateStyle: "full" });
275
+
276
+ function isDisabled(date: Date): boolean {
277
+ if (props.min !== undefined && date < startOfDay(props.min)) return true;
278
+ if (props.max !== undefined && date > startOfDay(props.max)) return true;
279
+ return props.disabled?.(date) === true;
280
+ }
281
+
282
+ /**
283
+ * Show the month containing `date`, if it is not already showing.
284
+ *
285
+ * The guard is load-bearing. `month` is a signal the whole grid is derived
286
+ * from, so writing a fresh `Date` for the month already on screen rebuilds
287
+ * all 42 cells — destroying the one the user just clicked, and with it the
288
+ * focus that was on it.
289
+ */
290
+ function showMonthOf(date: Date): void {
291
+ const current = month();
292
+ if (
293
+ current.getFullYear() === date.getFullYear() &&
294
+ current.getMonth() === date.getMonth()
295
+ ) {
296
+ return;
297
+ }
298
+ month(new Date(date.getFullYear(), date.getMonth(), 1));
299
+ }
300
+
301
+ function choose(date: Date): void {
302
+ if (isDisabled(date)) return;
303
+ cursor(date);
304
+ showMonthOf(date);
305
+ if (isRange) chooseInRange(date);
306
+ else props.onValueChange?.(date);
307
+ }
308
+
309
+ function chooseInRange(date: Date): void {
310
+ const next = nextRange(activeRange(), date);
311
+ draft(next);
312
+ // Drop the preview: it belonged to the span that just closed, and leaving
313
+ // it would paint a band to wherever the pointer happens to rest.
314
+ hovered(undefined);
315
+ props.onRangeChange?.(next);
316
+ }
317
+
318
+ function moveCursor(next: Date): void {
319
+ cursor(next);
320
+ // Follow the cursor across a month boundary, otherwise arrowing off the
321
+ // end of the grid moves focus to a cell that is not on screen.
322
+ if (
323
+ next.getMonth() !== month().getMonth() ||
324
+ next.getFullYear() !== month().getFullYear()
325
+ ) {
326
+ month(new Date(next.getFullYear(), next.getMonth(), 1));
327
+ }
328
+ focusCursor();
329
+ }
330
+
331
+ function focusCursor(): void {
332
+ // After the signal write, so the new cell exists and carries tabindex 0.
333
+ queueMicrotask(() => {
334
+ const grid = document.getElementById(gridId);
335
+ grid
336
+ ?.querySelector<HTMLElement>("[data-cursor]")
337
+ ?.focus({ preventScroll: true });
338
+ });
339
+ }
340
+
341
+ function onKeyDown(event: KeyboardEvent): void {
342
+ const from = cursor();
343
+ const moves: Record<string, () => Date> = {
344
+ ArrowLeft: () => addDays(from, -1),
345
+ ArrowRight: () => addDays(from, 1),
346
+ ArrowUp: () => addDays(from, -7),
347
+ ArrowDown: () => addDays(from, 7),
348
+ PageUp: () => addMonths(from, -1),
349
+ PageDown: () => addMonths(from, 1),
350
+ Home: () => addDays(from, -((from.getDay() - weekStartsOn + 7) % 7)),
351
+ End: () => addDays(from, 6 - ((from.getDay() - weekStartsOn + 7) % 7)),
352
+ };
353
+
354
+ const move = moves[event.key];
355
+ if (move !== undefined) {
356
+ event.preventDefault();
357
+ moveCursor(move());
358
+ return;
359
+ }
360
+ if (event.key === "Enter" || event.key === " ") {
361
+ event.preventDefault();
362
+ choose(from);
363
+ }
364
+ }
365
+
366
+ const weekdayNames = Array.from({ length: 7 }, (_unused, index) =>
367
+ weekdayLabel.format(new Date(2024, 0, 7 + ((weekStartsOn + index) % 7))),
368
+ );
369
+
370
+ return html`<div
371
+ data-slot="calendar"
372
+ class="${() => cn("bg-background w-fit rounded-md p-3", read(props.class))}"
373
+ >
374
+ <div class="flex items-center justify-between pb-4">
375
+ <button
376
+ type="button"
377
+ aria-label="Previous month"
378
+ class="${buttonVariants({ variant: "outline", size: "icon", class: "size-7" })}"
379
+ @click="${() => month(addMonths(month(), -1))}"
380
+ >${ChevronLeftIcon({ class: "size-4" })}</button>
381
+ <div id="${labelId}" aria-live="polite" class="text-sm font-medium">
382
+ ${() => monthLabel.format(month())}
383
+ </div>
384
+ <button
385
+ type="button"
386
+ aria-label="Next month"
387
+ class="${buttonVariants({ variant: "outline", size: "icon", class: "size-7" })}"
388
+ @click="${() => month(addMonths(month(), 1))}"
389
+ >${ChevronRightIcon({ class: "size-4" })}</button>
390
+ </div>
391
+ <div
392
+ role="grid"
393
+ id="${gridId}"
394
+ aria-labelledby="${labelId}"
395
+ class="grid grid-cols-7 gap-1"
396
+ @keydown="${onKeyDown}"
397
+ >
398
+ ${weekdayNames.map(
399
+ (name) => html`<div
400
+ role="columnheader"
401
+ aria-label="${name}"
402
+ class="text-muted-foreground flex size-8 items-center justify-center text-[0.8rem] font-normal"
403
+ >${name}</div>`,
404
+ )}
405
+ ${() => monthGrid(month(), weekStartsOn).map(renderDay)}
406
+ </div>
407
+ </div>`;
408
+
409
+ function renderDay(date: Date): Child {
410
+ const outside = date.getMonth() !== month().getMonth();
411
+ const position = (): RangePosition =>
412
+ isRange ? rangePositionOf(date) : "none";
413
+ const chosen = (): boolean => {
414
+ if (isRange) return position() !== "none";
415
+ const current = selected();
416
+ return current !== undefined && isSameDay(current, date);
417
+ };
418
+ const onCursor = (): boolean => isSameDay(cursor(), date);
419
+ const disabled = isDisabled(date);
420
+
421
+ return html`<button
422
+ type="button"
423
+ role="gridcell"
424
+ data-slot="calendar-day"
425
+ data-cursor="${() => (onCursor() ? "" : undefined)}"
426
+ data-outside="${outside ? "" : undefined}"
427
+ data-today="${isSameDay(date, today) ? "" : undefined}"
428
+ data-range="${() => {
429
+ const at = position();
430
+ return at === "none" ? undefined : at;
431
+ }}"
432
+ aria-label="${fullDate.format(date)}"
433
+ aria-selected="${() => (chosen() ? "true" : "false")}"
434
+ aria-current="${isSameDay(date, today) ? "date" : undefined}"
435
+ tabindex="${() => (onCursor() ? 0 : -1)}"
436
+ ?disabled="${disabled}"
437
+ class="${() =>
438
+ cn(
439
+ "flex size-8 items-center justify-center rounded-md p-0 text-sm font-normal transition-colors outline-none",
440
+ "hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px]",
441
+ "aria-selected:bg-primary aria-selected:text-primary-foreground aria-selected:hover:bg-primary",
442
+ "data-[today]:bg-accent data-[today]:aria-selected:bg-primary",
443
+ // The interior of a span is a flat band: square, and in accent
444
+ // rather than primary, so the two ends stay the emphasis.
445
+ "data-[range=middle]:bg-accent data-[range=middle]:text-accent-foreground data-[range=middle]:rounded-none",
446
+ "data-[range=start]:rounded-r-none data-[range=end]:rounded-l-none",
447
+ outside ? "text-muted-foreground opacity-50" : "",
448
+ disabled ? "pointer-events-none opacity-30" : "",
449
+ )}"
450
+ @click="${() => choose(date)}"
451
+ @pointerenter="${() => {
452
+ if (isRange && !disabled) hovered(date);
453
+ }}"
454
+ >${date.getDate()}</button>`;
455
+ }
456
+ });
457
+
458
+ /**
459
+ * `Intl.Locale.getWeekInfo` is not available everywhere yet, and neither is
460
+ * `navigator` on the server. Monday is the fallback — the ISO week start, and
461
+ * the right answer for most of the world.
462
+ */
463
+ function safeWeekStart(locale: string | undefined): number {
464
+ try {
465
+ if (typeof navigator === "undefined" && locale === undefined) return 1;
466
+ return localeWeekStart(locale);
467
+ } catch {
468
+ return 1;
469
+ }
470
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Carousel — a horizontally scrolling strip of slides.
3
+ *
4
+ * shadcn wraps `embla-carousel`, which animates a transform with its own
5
+ * requestAnimationFrame loop and reimplements drag physics on top. nebula uses
6
+ * CSS scroll-snap and `scrollBy`.
7
+ *
8
+ * That is not only smaller, it is better behaved. Native scrolling brings
9
+ * touch momentum, trackpad gestures, the platform's own overscroll, and
10
+ * keyboard scrolling — all things a transform-based carousel has to rebuild
11
+ * and usually gets partly wrong. `scroll-behavior: smooth` handles the
12
+ * animation, and `prefers-reduced-motion` turns it off without a branch here.
13
+ *
14
+ * The strip is a `region` with `aria-roledescription="carousel"`, and slides
15
+ * are `group`s labelled "N of M". A screen reader user otherwise has no way to
16
+ * tell how much is off-screen — the visual affordance of a strip running off
17
+ * the edge carries none of that.
18
+ */
19
+
20
+ import { component, html, onMount, onUnmount, signal } from "@c9up/aurora";
21
+ import { buttonVariants } from "../atoms/Button.js";
22
+ import type { Child } from "../lib/children.js";
23
+ import { cn } from "../lib/cn.js";
24
+ import { ArrowLeftIcon, ArrowRightIcon } from "../lib/icons.js";
25
+ import { uid } from "../lib/id.js";
26
+ import { type Reactive, read } from "../lib/props.js";
27
+
28
+ export interface CarouselProps {
29
+ slides: readonly Child[];
30
+ /** Announced before the slides. */
31
+ label?: string;
32
+ orientation?: "horizontal" | "vertical";
33
+ class?: Reactive<string>;
34
+ slideClass?: Reactive<string>;
35
+ }
36
+
37
+ export const Carousel = component<CarouselProps>((props) => {
38
+ const trackId = uid("carousel-track");
39
+ const horizontal = props.orientation !== "vertical";
40
+ const atStart = signal(true);
41
+ const atEnd = signal(false);
42
+
43
+ function track(): HTMLElement | null {
44
+ return document.getElementById(trackId);
45
+ }
46
+
47
+ /**
48
+ * Recompute whether either end is reached.
49
+ *
50
+ * The one-pixel tolerance is load-bearing: with fractional device pixel
51
+ * ratios, `scrollLeft` at the far end lands a fraction short of
52
+ * `scrollWidth - clientWidth`, and an exact comparison leaves the "next"
53
+ * button enabled on a carousel that cannot scroll any further.
54
+ */
55
+ function syncEdges(): void {
56
+ const element = track();
57
+ if (element === null) return;
58
+ const position = horizontal ? element.scrollLeft : element.scrollTop;
59
+ const total = horizontal ? element.scrollWidth : element.scrollHeight;
60
+ const visible = horizontal ? element.clientWidth : element.clientHeight;
61
+ atStart(position <= 1);
62
+ atEnd(position + visible >= total - 1);
63
+ }
64
+
65
+ function scrollByPage(direction: 1 | -1): void {
66
+ const element = track();
67
+ if (element === null) return;
68
+ const distance =
69
+ (horizontal ? element.clientWidth : element.clientHeight) * direction;
70
+ element.scrollBy(
71
+ horizontal
72
+ ? { left: distance, behavior: "smooth" }
73
+ : { top: distance, behavior: "smooth" },
74
+ );
75
+ }
76
+
77
+ function onKeyDown(event: KeyboardEvent): void {
78
+ const back = horizontal ? "ArrowLeft" : "ArrowUp";
79
+ const forward = horizontal ? "ArrowRight" : "ArrowDown";
80
+ if (event.key === back) {
81
+ event.preventDefault();
82
+ scrollByPage(-1);
83
+ } else if (event.key === forward) {
84
+ event.preventDefault();
85
+ scrollByPage(1);
86
+ }
87
+ }
88
+
89
+ onMount(() => {
90
+ syncEdges();
91
+ const element = track();
92
+ element?.addEventListener("scroll", syncEdges, { passive: true });
93
+ window.addEventListener("resize", syncEdges);
94
+ });
95
+
96
+ onUnmount(() => {
97
+ track()?.removeEventListener("scroll", syncEdges);
98
+ window.removeEventListener("resize", syncEdges);
99
+ });
100
+
101
+ const count = props.slides.length;
102
+
103
+ return html`<div
104
+ data-slot="carousel"
105
+ role="region"
106
+ aria-roledescription="carousel"
107
+ aria-label="${props.label ?? "Carousel"}"
108
+ class="${() => cn("relative", read(props.class))}"
109
+ @keydown="${onKeyDown}"
110
+ >
111
+ <div
112
+ data-slot="carousel-track"
113
+ id="${trackId}"
114
+ tabindex="0"
115
+ class="${cn(
116
+ "flex snap-mandatory gap-4 overflow-auto scroll-smooth outline-none motion-reduce:scroll-auto",
117
+ horizontal
118
+ ? "snap-x flex-row [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
119
+ : "snap-y max-h-96 flex-col [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
120
+ )}"
121
+ >
122
+ ${props.slides.map(
123
+ (child, index) => html`<div
124
+ data-slot="carousel-slide"
125
+ role="group"
126
+ aria-roledescription="slide"
127
+ aria-label="${`${index + 1} of ${count}`}"
128
+ class="${() => cn("min-w-0 shrink-0 grow-0 basis-full snap-start", read(props.slideClass))}"
129
+ >${child}</div>`,
130
+ )}
131
+ </div>
132
+ <button
133
+ type="button"
134
+ data-slot="carousel-previous"
135
+ aria-label="Previous slide"
136
+ ?disabled="${() => atStart()}"
137
+ class="${cn(
138
+ buttonVariants({ variant: "outline", size: "icon" }),
139
+ "absolute size-8 rounded-full",
140
+ horizontal
141
+ ? "top-1/2 -left-12 -translate-y-1/2"
142
+ : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
143
+ )}"
144
+ @click="${() => scrollByPage(-1)}"
145
+ >${ArrowLeftIcon({ class: "size-4" })}</button>
146
+ <button
147
+ type="button"
148
+ data-slot="carousel-next"
149
+ aria-label="Next slide"
150
+ ?disabled="${() => atEnd()}"
151
+ class="${cn(
152
+ buttonVariants({ variant: "outline", size: "icon" }),
153
+ "absolute size-8 rounded-full",
154
+ horizontal
155
+ ? "top-1/2 -right-12 -translate-y-1/2"
156
+ : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
157
+ )}"
158
+ @click="${() => scrollByPage(1)}"
159
+ >${ArrowRightIcon({ class: "size-4" })}</button>
160
+ </div>`;
161
+ });