@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,141 @@
1
+ /**
2
+ * DatePicker — a Calendar in a popover, behind a button.
3
+ *
4
+ * shadcn composes this out of Popover, Button and Calendar rather than
5
+ * shipping it; nebula ships it, because the composition has three details that
6
+ * are easy to get wrong and pointless to rediscover per project.
7
+ *
8
+ * The popover closes on selection — a date picker that stays open after a
9
+ * choice leaves the user hunting for the way out. Focus returns to the
10
+ * trigger, so the tab order does not restart at the top of the page. And the
11
+ * trigger reads the chosen date through `Intl.DateTimeFormat`, so it renders
12
+ * in the user's locale rather than an American ordering everyone else has to
13
+ * decode.
14
+ *
15
+ * A hidden input carries the value as ISO `YYYY-MM-DD`, which is what a server
16
+ * wants — never the localised string on the button.
17
+ */
18
+
19
+ import { component, html, signal } from "@c9up/aurora";
20
+ import { buttonVariants } from "../atoms/Button.js";
21
+ import { cn } from "../lib/cn.js";
22
+ import { CalendarIcon } from "../lib/icons.js";
23
+ import { uid } from "../lib/id.js";
24
+ import { zoomInOut } from "../lib/motion.js";
25
+ import { type Reactive, read, readOr } from "../lib/props.js";
26
+ import { controllable } from "../primitives/controllable.js";
27
+ import { floatingSurface } from "../primitives/floatingSurface.js";
28
+ import { focusSilently } from "../primitives/focusable.js";
29
+ import { Calendar, startOfDay } from "./Calendar.js";
30
+
31
+ export interface DatePickerProps {
32
+ name?: string;
33
+ value?: Reactive<Date | undefined>;
34
+ defaultValue?: Date;
35
+ placeholder?: string;
36
+ min?: Date;
37
+ max?: Date;
38
+ disabled?: Reactive<boolean>;
39
+ invalid?: Reactive<boolean>;
40
+ locale?: string;
41
+ class?: Reactive<string>;
42
+ onValueChange?: (date: Date) => void;
43
+ }
44
+
45
+ /** `YYYY-MM-DD` from a local date, without a timezone round-trip. */
46
+ export function toISODate(date: Date): string {
47
+ const month = String(date.getMonth() + 1).padStart(2, "0");
48
+ const day = String(date.getDate()).padStart(2, "0");
49
+ return `${date.getFullYear()}-${month}-${day}`;
50
+ }
51
+
52
+ export const DatePicker = component<DatePickerProps>((props) => {
53
+ const triggerId = uid("date-picker-trigger");
54
+ const contentId = uid("date-picker-content");
55
+ const open = signal(false);
56
+
57
+ const selection = controllable<Date | undefined>({
58
+ value: props.value,
59
+ initial: props.defaultValue,
60
+ onChange: (next) => {
61
+ if (next !== undefined) props.onValueChange?.(next);
62
+ },
63
+ });
64
+
65
+ const format = new Intl.DateTimeFormat(props.locale, { dateStyle: "medium" });
66
+
67
+ function choose(date: Date): void {
68
+ selection.set(startOfDay(date));
69
+ open(false);
70
+ focusSilently(document.getElementById(triggerId));
71
+ }
72
+
73
+ floatingSurface({
74
+ anchor: () => document.getElementById(triggerId),
75
+ open: () => open(),
76
+ onClose: () => open(false),
77
+ placement: "bottom-start",
78
+ offset: 4,
79
+ trapFocus: true,
80
+ // The cursor cell, not the first focusable — landing on the previous-month
81
+ // arrow means the first arrow key pages the calendar instead of moving a
82
+ // day, which is not what a keyboard user reaching a date grid expects.
83
+ initialFocus: (content) => content.querySelector("[data-cursor]"),
84
+ content: () =>
85
+ html`<div
86
+ data-slot="date-picker-content"
87
+ id="${contentId}"
88
+ role="dialog"
89
+ aria-label="Choose a date"
90
+ class="${cn(
91
+ "bg-popover text-popover-foreground z-50 rounded-md border p-0 shadow-md outline-none",
92
+ zoomInOut,
93
+ )}"
94
+ >
95
+ ${Calendar({
96
+ value: () => selection.current(),
97
+ defaultMonth: selection.current(),
98
+ min: props.min,
99
+ max: props.max,
100
+ locale: props.locale,
101
+ onValueChange: choose,
102
+ })}
103
+ </div>`,
104
+ });
105
+
106
+ return html`<div data-slot="date-picker" class="${() => cn("inline-flex", read(props.class))}">
107
+ <button
108
+ type="button"
109
+ id="${triggerId}"
110
+ data-slot="date-picker-trigger"
111
+ aria-haspopup="dialog"
112
+ aria-expanded="${() => (open() ? "true" : "false")}"
113
+ aria-controls="${() => (open() ? contentId : undefined)}"
114
+ aria-invalid="${() => (read(props.invalid) === true ? "true" : undefined)}"
115
+ data-placeholder="${() => (selection.current() === undefined ? "" : undefined)}"
116
+ ?disabled="${() => readOr(props.disabled, false)}"
117
+ class="${buttonVariants({
118
+ variant: "outline",
119
+ class:
120
+ "w-full justify-start gap-2 font-normal data-[placeholder]:text-muted-foreground",
121
+ })}"
122
+ @click="${() => open(!open())}"
123
+ >
124
+ ${CalendarIcon({ class: "size-4" })}
125
+ <span>${() => {
126
+ const current = selection.current();
127
+ return current === undefined
128
+ ? (props.placeholder ?? "Pick a date")
129
+ : format.format(current);
130
+ }}</span>
131
+ </button>
132
+ <input
133
+ type="hidden"
134
+ name="${props.name}"
135
+ .value="${() => {
136
+ const current = selection.current();
137
+ return current === undefined ? "" : toISODate(current);
138
+ }}"
139
+ />
140
+ </div>`;
141
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * DateRangePicker — a Calendar in range mode, behind a button.
3
+ *
4
+ * The same shell as `DatePicker`, and deliberately so: the two differ in what
5
+ * they collect, not in how they behave. Both close on completion, both return
6
+ * focus to the trigger, both format through `Intl` and both post ISO dates.
7
+ *
8
+ * The one behavioural difference is when to close. A single date is done in
9
+ * one click; a range is not done until the second, so the popover stays open
10
+ * between them. Closing on the first click and reopening for the second is the
11
+ * usual bug here, and it makes the control feel broken.
12
+ *
13
+ * Two hidden inputs rather than one comma-joined field. A server parsing
14
+ * `from`/`to` separately never has to split a string, and an empty `to` is
15
+ * unambiguous.
16
+ */
17
+
18
+ import { component, html, signal } from "@c9up/aurora";
19
+ import { buttonVariants } from "../atoms/Button.js";
20
+ import { cn } from "../lib/cn.js";
21
+ import { CalendarIcon } from "../lib/icons.js";
22
+ import { uid } from "../lib/id.js";
23
+ import { zoomInOut } from "../lib/motion.js";
24
+ import { type Reactive, read, readOr } from "../lib/props.js";
25
+ import { controllable } from "../primitives/controllable.js";
26
+ import { floatingSurface } from "../primitives/floatingSurface.js";
27
+ import { focusSilently } from "../primitives/focusable.js";
28
+ import { Calendar, type DateRange, startOfDay } from "./Calendar.js";
29
+ import { toISODate } from "./DatePicker.js";
30
+
31
+ export interface DateRangePickerProps {
32
+ /** Base name for the hidden inputs — `"stay"` posts `stay_from` / `stay_to`. */
33
+ name?: string;
34
+ value?: Reactive<DateRange | undefined>;
35
+ defaultValue?: DateRange;
36
+ placeholder?: string;
37
+ min?: Date;
38
+ max?: Date;
39
+ disabled?: Reactive<boolean>;
40
+ invalid?: Reactive<boolean>;
41
+ locale?: string;
42
+ class?: Reactive<string>;
43
+ /** Fires on both clicks — `to` is absent after the first. */
44
+ onValueChange?: (range: DateRange) => void;
45
+ }
46
+
47
+ export const DateRangePicker = component<DateRangePickerProps>((props) => {
48
+ const triggerId = uid("date-range-trigger");
49
+ const contentId = uid("date-range-content");
50
+ const open = signal(false);
51
+
52
+ const selection = controllable<DateRange | undefined>({
53
+ value: props.value,
54
+ initial: props.defaultValue,
55
+ onChange: (next) => {
56
+ if (next !== undefined) props.onValueChange?.(next);
57
+ },
58
+ });
59
+
60
+ const format = new Intl.DateTimeFormat(props.locale, { dateStyle: "medium" });
61
+
62
+ function pick(range: DateRange): void {
63
+ selection.set({
64
+ from: startOfDay(range.from),
65
+ to: range.to === undefined ? undefined : startOfDay(range.to),
66
+ });
67
+ // Only a completed span closes the popover.
68
+ if (range.to === undefined) return;
69
+ open(false);
70
+ focusSilently(document.getElementById(triggerId));
71
+ }
72
+
73
+ function label(): string {
74
+ const current = selection.current();
75
+ if (current === undefined) return props.placeholder ?? "Pick a date range";
76
+ if (current.to === undefined) return `${format.format(current.from)} — …`;
77
+ return `${format.format(current.from)} — ${format.format(current.to)}`;
78
+ }
79
+
80
+ floatingSurface({
81
+ anchor: () => document.getElementById(triggerId),
82
+ open: () => open(),
83
+ onClose: () => open(false),
84
+ placement: "bottom-start",
85
+ offset: 4,
86
+ trapFocus: true,
87
+ initialFocus: (content) => content.querySelector("[data-cursor]"),
88
+ content: () =>
89
+ html`<div
90
+ data-slot="date-range-picker-content"
91
+ id="${contentId}"
92
+ role="dialog"
93
+ aria-label="Choose a date range"
94
+ class="${cn(
95
+ "bg-popover text-popover-foreground z-50 rounded-md border p-0 shadow-md outline-none",
96
+ zoomInOut,
97
+ )}"
98
+ >
99
+ ${Calendar({
100
+ mode: "range",
101
+ range: () => selection.current(),
102
+ defaultMonth: selection.current()?.from,
103
+ min: props.min,
104
+ max: props.max,
105
+ locale: props.locale,
106
+ onRangeChange: pick,
107
+ })}
108
+ </div>`,
109
+ });
110
+
111
+ const isoOf = (date: Date | undefined): string =>
112
+ date === undefined ? "" : toISODate(date);
113
+
114
+ return html`<div
115
+ data-slot="date-range-picker"
116
+ class="${() => cn("inline-flex", read(props.class))}"
117
+ >
118
+ <button
119
+ type="button"
120
+ id="${triggerId}"
121
+ data-slot="date-range-picker-trigger"
122
+ aria-haspopup="dialog"
123
+ aria-expanded="${() => (open() ? "true" : "false")}"
124
+ aria-controls="${() => (open() ? contentId : undefined)}"
125
+ aria-invalid="${() => (read(props.invalid) === true ? "true" : undefined)}"
126
+ data-placeholder="${() => (selection.current() === undefined ? "" : undefined)}"
127
+ ?disabled="${() => readOr(props.disabled, false)}"
128
+ class="${buttonVariants({
129
+ variant: "outline",
130
+ class:
131
+ "w-full justify-start gap-2 font-normal data-[placeholder]:text-muted-foreground",
132
+ })}"
133
+ @click="${() => open(!open())}"
134
+ >
135
+ ${CalendarIcon({ class: "size-4" })}
136
+ <span>${label}</span>
137
+ </button>
138
+ <input
139
+ type="hidden"
140
+ name="${props.name === undefined ? undefined : `${props.name}_from`}"
141
+ .value="${() => isoOf(selection.current()?.from)}"
142
+ />
143
+ <input
144
+ type="hidden"
145
+ name="${props.name === undefined ? undefined : `${props.name}_to`}"
146
+ .value="${() => isoOf(selection.current()?.to)}"
147
+ />
148
+ </div>`;
149
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Dialog — a modal window over the page.
3
+ *
4
+ * `modalSurface` supplies the contract (trap, scroll lock, backdrop dismissal,
5
+ * hiding the page from assistive technology); this component supplies the
6
+ * markup and the trigger.
7
+ *
8
+ * The `panel` callback matters more than it looks. The overlay's root spans
9
+ * the viewport so the backdrop can, which means containment has to be answered
10
+ * against the panel — check the root and every click on the page counts as
11
+ * inside, and clicking the backdrop silently stops closing the dialog.
12
+ *
13
+ * `aria-labelledby` points at the title and `aria-describedby` at the
14
+ * description, so a screen reader announces what the dialog is for on open. A
15
+ * dialog with neither announces only "dialog", which is why `title` is
16
+ * required rather than optional — `srOnlyTitle` is there for designs that show
17
+ * no visible heading.
18
+ */
19
+
20
+ import { component, html, signal } from "@c9up/aurora";
21
+ import type { Child } from "../lib/children.js";
22
+ import { type Slot, slot } from "../lib/children.js";
23
+ import { cn } from "../lib/cn.js";
24
+ import { XIcon } from "../lib/icons.js";
25
+ import { uid } from "../lib/id.js";
26
+ import { fadeInOut, zoomInOut } from "../lib/motion.js";
27
+ import { type Reactive, read } from "../lib/props.js";
28
+ import { controllable } from "../primitives/controllable.js";
29
+ import { modalSurface } from "../primitives/modalSurface.js";
30
+
31
+ export const dialogBackdropClasses = "fixed inset-0 z-50 bg-black/50";
32
+
33
+ export const dialogPanelClasses =
34
+ "bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg outline-none sm:max-w-lg";
35
+
36
+ export interface DialogProps {
37
+ /** Rendered inside the trigger button. Omit to drive `open` yourself. */
38
+ trigger?: Slot;
39
+ /** Announced on open. Hide it visually with `srOnlyTitle`. */
40
+ title: Child;
41
+ description?: Child;
42
+ children?: Slot;
43
+ /** Actions, laid out bottom-right. */
44
+ footer?: Slot;
45
+ open?: Reactive<boolean>;
46
+ defaultOpen?: boolean;
47
+ onOpenChange?: (open: boolean) => void;
48
+ /** Keep the title for screen readers but out of the layout. */
49
+ srOnlyTitle?: boolean;
50
+ /** Hide the corner close button. Escape and the backdrop still work. */
51
+ hideCloseButton?: boolean;
52
+ class?: Reactive<string>;
53
+ triggerClass?: Reactive<string>;
54
+ contentClass?: Reactive<string>;
55
+ }
56
+
57
+ export const Dialog = component<DialogProps>((props) => {
58
+ const triggerId = uid("dialog-trigger");
59
+ const panelId = uid("dialog-panel");
60
+ const titleId = uid("dialog-title");
61
+ const descriptionId = uid("dialog-description");
62
+
63
+ const state = controllable<boolean>({
64
+ value: props.open,
65
+ initial: props.defaultOpen ?? false,
66
+ onChange: props.onOpenChange,
67
+ });
68
+
69
+ modalSurface({
70
+ open: () => state.current(),
71
+ onClose: () => state.set(false),
72
+ panel: (root) => root.querySelector(`#${CSS.escape(panelId)}`),
73
+ returnFocus: () => document.getElementById(triggerId),
74
+ content: () =>
75
+ html`<div data-slot="dialog-overlay" class="${cn("fixed inset-0 z-50", fadeInOut)}">
76
+ <div data-slot="dialog-backdrop" class="${dialogBackdropClasses}"></div>
77
+ <div
78
+ data-slot="dialog-content"
79
+ id="${panelId}"
80
+ role="dialog"
81
+ aria-modal="true"
82
+ aria-labelledby="${titleId}"
83
+ aria-describedby="${props.description === undefined ? undefined : descriptionId}"
84
+ tabindex="-1"
85
+ class="${cn(dialogPanelClasses, zoomInOut, read(props.contentClass))}"
86
+ >
87
+ <div data-slot="dialog-header" class="flex flex-col gap-2 text-center sm:text-left">
88
+ <h2
89
+ id="${titleId}"
90
+ data-slot="dialog-title"
91
+ class="${cn(
92
+ "text-lg leading-none font-semibold",
93
+ props.srOnlyTitle === true ? "sr-only" : "",
94
+ )}"
95
+ >${props.title}</h2>
96
+ ${
97
+ props.description === undefined
98
+ ? null
99
+ : html`<p
100
+ id="${descriptionId}"
101
+ data-slot="dialog-description"
102
+ class="text-muted-foreground text-sm"
103
+ >${props.description}</p>`
104
+ }
105
+ </div>
106
+ ${slot(props.children)}
107
+ ${
108
+ props.footer === undefined
109
+ ? null
110
+ : html`<div
111
+ data-slot="dialog-footer"
112
+ class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end"
113
+ >${slot(props.footer)}</div>`
114
+ }
115
+ ${
116
+ props.hideCloseButton === true
117
+ ? null
118
+ : html`<button
119
+ type="button"
120
+ data-slot="dialog-close"
121
+ class="ring-offset-background focus-visible:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
122
+ @click="${() => state.set(false)}"
123
+ >
124
+ ${XIcon({ class: "size-4" })}
125
+ <span class="sr-only">Close</span>
126
+ </button>`
127
+ }
128
+ </div>
129
+ </div>`,
130
+ });
131
+
132
+ if (props.trigger === undefined) {
133
+ // No trigger: the dialog is driven entirely by the `open` prop. Something
134
+ // still has to be in the tree for the component to mount into, and the
135
+ // surface itself lives in a portal.
136
+ return html`<span data-slot="dialog" hidden></span>`;
137
+ }
138
+
139
+ return html`<button
140
+ type="button"
141
+ data-slot="dialog-trigger"
142
+ id="${triggerId}"
143
+ aria-haspopup="dialog"
144
+ aria-expanded="${() => (state.current() ? "true" : "false")}"
145
+ class="${() => cn(read(props.triggerClass))}"
146
+ @click="${() => state.set(true)}"
147
+ >${slot(props.trigger)}</button>`;
148
+ });
149
+
150
+ /** A dialog driven purely by a signal, with no trigger of its own. */
151
+ export function useDialog(initial = false): {
152
+ open: () => boolean;
153
+ show(): void;
154
+ hide(): void;
155
+ } {
156
+ const open = signal(initial);
157
+ return {
158
+ open: () => open(),
159
+ show: () => open(true),
160
+ hide: () => open(false),
161
+ };
162
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Drawer — a bottom sheet you can swipe away.
3
+ *
4
+ * A Sheet from the bottom edge, plus the one gesture that makes it feel native
5
+ * on a phone: drag the panel down to dismiss it.
6
+ *
7
+ * The drag rules are what separate this from a panel that merely follows the
8
+ * finger:
9
+ *
10
+ * - Downward only. Dragging up would peel the panel off its edge and leave a
11
+ * gap under it.
12
+ * - Released past a threshold, it closes; short of it, it springs back. A
13
+ * distance threshold alone punishes a fast flick, so velocity counts too —
14
+ * a quick short drag dismisses, which is what the gesture means.
15
+ * - The transition is disabled during the drag and restored on release.
16
+ * Leaving it on makes the panel lag the finger by its own duration, which
17
+ * reads as a broken gesture rather than a smooth one.
18
+ *
19
+ * The grab handle is decorative. Everything the drag does, the close button,
20
+ * Escape and the backdrop already do — a gesture must never be the only way
21
+ * out of a modal surface.
22
+ */
23
+
24
+ import { component, html } from "@c9up/aurora";
25
+ import type { Child } from "../lib/children.js";
26
+ import { type Slot, slot } from "../lib/children.js";
27
+ import { cn } from "../lib/cn.js";
28
+ import { uid } from "../lib/id.js";
29
+ import { fadeInOut, slideFrom } from "../lib/motion.js";
30
+ import { type Reactive, read } from "../lib/props.js";
31
+ import { controllable } from "../primitives/controllable.js";
32
+ import { modalSurface } from "../primitives/modalSurface.js";
33
+ import { dialogBackdropClasses } from "./Dialog.js";
34
+
35
+ /** Drag this far down and release to dismiss, in px. */
36
+ const DISMISS_DISTANCE = 120;
37
+ /** Or release faster than this, in px per millisecond. */
38
+ const DISMISS_VELOCITY = 0.5;
39
+
40
+ export interface DrawerProps {
41
+ trigger?: Slot;
42
+ title: Child;
43
+ description?: Child;
44
+ children?: Slot;
45
+ footer?: Slot;
46
+ open?: Reactive<boolean>;
47
+ defaultOpen?: boolean;
48
+ onOpenChange?: (open: boolean) => void;
49
+ /** Turn off drag-to-dismiss — for a drawer holding a scrollable list. */
50
+ disableDrag?: boolean;
51
+ triggerClass?: Reactive<string>;
52
+ contentClass?: Reactive<string>;
53
+ }
54
+
55
+ export const Drawer = component<DrawerProps>((props) => {
56
+ const triggerId = uid("drawer-trigger");
57
+ const panelId = uid("drawer-panel");
58
+ const titleId = uid("drawer-title");
59
+ const descriptionId = uid("drawer-description");
60
+
61
+ const state = controllable<boolean>({
62
+ value: props.open,
63
+ initial: props.defaultOpen ?? false,
64
+ onChange: props.onOpenChange,
65
+ });
66
+
67
+ function onPointerDown(event: PointerEvent): void {
68
+ if (props.disableDrag === true) return;
69
+ const panel = document.getElementById(panelId);
70
+ if (panel === null) return;
71
+
72
+ const startY = event.clientY;
73
+ const startedAt = performance.now();
74
+ let offset = 0;
75
+ panel.setPointerCapture(event.pointerId);
76
+ panel.style.transition = "none";
77
+
78
+ const onMove = (move: PointerEvent): void => {
79
+ offset = Math.max(0, move.clientY - startY);
80
+ panel.style.transform = `translateY(${offset}px)`;
81
+ };
82
+
83
+ const onUp = (): void => {
84
+ panel.releasePointerCapture(event.pointerId);
85
+ panel.removeEventListener("pointermove", onMove);
86
+ panel.removeEventListener("pointerup", onUp);
87
+ panel.removeEventListener("pointercancel", onUp);
88
+
89
+ // Restore the transition before deciding, so both outcomes animate.
90
+ panel.style.transition = "";
91
+ const velocity = offset / Math.max(1, performance.now() - startedAt);
92
+
93
+ if (offset > DISMISS_DISTANCE || velocity > DISMISS_VELOCITY) {
94
+ panel.style.transform = "";
95
+ state.set(false);
96
+ } else {
97
+ panel.style.transform = "";
98
+ }
99
+ };
100
+
101
+ panel.addEventListener("pointermove", onMove);
102
+ panel.addEventListener("pointerup", onUp);
103
+ panel.addEventListener("pointercancel", onUp);
104
+ }
105
+
106
+ modalSurface({
107
+ open: () => state.current(),
108
+ onClose: () => state.set(false),
109
+ panel: (root) => root.querySelector(`#${CSS.escape(panelId)}`),
110
+ returnFocus: () => document.getElementById(triggerId),
111
+ content: () =>
112
+ html`<div data-slot="drawer-overlay" class="${cn("fixed inset-0 z-50", fadeInOut)}">
113
+ <div class="${dialogBackdropClasses}"></div>
114
+ <div
115
+ data-slot="drawer-content"
116
+ id="${panelId}"
117
+ role="dialog"
118
+ aria-modal="true"
119
+ aria-labelledby="${titleId}"
120
+ aria-describedby="${props.description === undefined ? undefined : descriptionId}"
121
+ tabindex="-1"
122
+ class="${cn(
123
+ "bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex max-h-[80vh] touch-none flex-col gap-4 rounded-t-lg border-t p-6 shadow-lg outline-none",
124
+ slideFrom("bottom"),
125
+ read(props.contentClass),
126
+ )}"
127
+ @pointerdown="${onPointerDown}"
128
+ >
129
+ <div
130
+ data-slot="drawer-handle"
131
+ aria-hidden="true"
132
+ class="bg-muted mx-auto h-1.5 w-12 shrink-0 rounded-full"
133
+ ></div>
134
+ <div class="flex flex-col gap-1.5 text-center sm:text-left">
135
+ <h2 id="${titleId}" class="text-foreground font-semibold">${props.title}</h2>
136
+ ${
137
+ props.description === undefined
138
+ ? null
139
+ : html`<p id="${descriptionId}" class="text-muted-foreground text-sm">
140
+ ${props.description}
141
+ </p>`
142
+ }
143
+ </div>
144
+ <div class="flex-1 overflow-y-auto">${slot(props.children)}</div>
145
+ ${
146
+ props.footer === undefined
147
+ ? null
148
+ : html`<div class="mt-auto flex flex-col gap-2">${slot(props.footer)}</div>`
149
+ }
150
+ </div>
151
+ </div>`,
152
+ });
153
+
154
+ if (props.trigger === undefined) {
155
+ return html`<span data-slot="drawer" hidden></span>`;
156
+ }
157
+
158
+ return html`<button
159
+ type="button"
160
+ data-slot="drawer-trigger"
161
+ id="${triggerId}"
162
+ aria-haspopup="dialog"
163
+ aria-expanded="${() => (state.current() ? "true" : "false")}"
164
+ class="${() => cn(read(props.triggerClass))}"
165
+ @click="${() => state.set(true)}"
166
+ >${slot(props.trigger)}</button>`;
167
+ });