@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,112 @@
1
+ /**
2
+ * DropdownMenu — a menu opened from a button.
3
+ *
4
+ * The panel, its entries and its keyboard model all come from `./menu.js`;
5
+ * this component contributes the trigger and the open/close state.
6
+ *
7
+ * Two behaviours are specific to a button-opened menu:
8
+ *
9
+ * - Opening with the keyboard focuses the first item; opening with the pointer
10
+ * does not. A keyboard user has no other way in, while pre-highlighting an
11
+ * item for a mouse user suggests it is about to be chosen.
12
+ * - ArrowDown on the trigger opens the menu *and* enters it, which is the
13
+ * behaviour of every native menu button and the one users try first.
14
+ */
15
+
16
+ import { component, html } from "@c9up/aurora";
17
+ import { type Slot, slot } from "../lib/children.js";
18
+ import { cn } from "../lib/cn.js";
19
+ import { uid } from "../lib/id.js";
20
+ import { type Reactive, read } from "../lib/props.js";
21
+ import { controllable } from "../primitives/controllable.js";
22
+ import type { Placement } from "../primitives/floating.js";
23
+ import { floatingSurface } from "../primitives/floatingSurface.js";
24
+ import { type MenuEntry, menuPanel, wireMenu } from "../primitives/menu.js";
25
+
26
+ export interface DropdownMenuProps {
27
+ trigger?: Slot;
28
+ entries: readonly MenuEntry[];
29
+ open?: Reactive<boolean>;
30
+ defaultOpen?: boolean;
31
+ onOpenChange?: (open: boolean) => void;
32
+ placement?: Placement;
33
+ class?: Reactive<string>;
34
+ triggerClass?: Reactive<string>;
35
+ contentClass?: Reactive<string>;
36
+ }
37
+
38
+ export const DropdownMenu = component<DropdownMenuProps>((props) => {
39
+ const triggerId = uid("dropdown-trigger");
40
+ const contentId = uid("dropdown-content");
41
+
42
+ const state = controllable<boolean>({
43
+ value: props.open,
44
+ initial: props.defaultOpen ?? false,
45
+ onChange: props.onOpenChange,
46
+ });
47
+
48
+ // Set by the trigger's key handler just before opening, and consumed by
49
+ // `onOpened`. The panel does not exist yet at the moment of the keypress, so
50
+ // the intent has to survive the gap.
51
+ let enterOnOpen = false;
52
+ let unwire: (() => void) | null = null;
53
+
54
+ function close(): void {
55
+ state.set(false);
56
+ }
57
+
58
+ floatingSurface({
59
+ anchor: () => document.getElementById(triggerId),
60
+ open: () => state.current(),
61
+ onClose: close,
62
+ placement: props.placement ?? "bottom-start",
63
+ offset: 4,
64
+ content: () =>
65
+ menuPanel({
66
+ id: contentId,
67
+ entries: props.entries,
68
+ onCloseAll: close,
69
+ labelledBy: triggerId,
70
+ class: read(props.contentClass),
71
+ }),
72
+ onOpened: (panel) => {
73
+ const enterItems = enterOnOpen;
74
+ enterOnOpen = false;
75
+ unwire = wireMenu(panel, {
76
+ onCloseAll: close,
77
+ autoFocusFirst: enterItems,
78
+ });
79
+ // The panel takes focus only when no item did — it needs to hold focus
80
+ // so Escape and the arrows reach it rather than the page behind, but
81
+ // focusing it unconditionally steals focus straight back from the item
82
+ // `autoFocusFirst` just landed on, and opening with the keyboard would
83
+ // never reach the first entry.
84
+ if (!enterItems) panel.focus({ preventScroll: true });
85
+ },
86
+ onClosed: () => {
87
+ unwire?.();
88
+ unwire = null;
89
+ },
90
+ });
91
+
92
+ function onTriggerKeyDown(event: KeyboardEvent): void {
93
+ if (event.key !== "ArrowDown" && event.key !== "Enter" && event.key !== " ")
94
+ return;
95
+ event.preventDefault();
96
+ enterOnOpen = true;
97
+ state.set(true);
98
+ }
99
+
100
+ return html`<button
101
+ type="button"
102
+ data-slot="dropdown-menu-trigger"
103
+ id="${triggerId}"
104
+ aria-haspopup="menu"
105
+ aria-expanded="${() => (state.current() ? "true" : "false")}"
106
+ aria-controls="${() => (state.current() ? contentId : undefined)}"
107
+ data-state="${() => (state.current() ? "open" : "closed")}"
108
+ class="${() => cn(read(props.triggerClass))}"
109
+ @click="${() => state.set(!state.current())}"
110
+ @keydown="${onTriggerKeyDown}"
111
+ >${slot(props.trigger)}</button>`;
112
+ });
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Form — binding between Aurora's form controller and nebula's field markup.
3
+ *
4
+ * shadcn's Form is a set of adapters over `react-hook-form`, and none of that
5
+ * layer is needed here: Aurora already ships `form()`, a reactive controller
6
+ * with values, per-field errors, touched state, submit-in-flight and optional
7
+ * rune/zod-shaped validation. Reimplementing it would be a second source of
8
+ * truth for the same thing.
9
+ *
10
+ * So this file is only the join. It wires one field to one `Field` plus one
11
+ * control, and takes care of the three things that otherwise get forgotten:
12
+ * `aria-invalid` when the field has an error, `aria-describedby` pointing at
13
+ * the error element, and marking the field touched on blur so an error appears
14
+ * after leaving a field rather than while typing in it.
15
+ *
16
+ * const login = form({
17
+ * initial: { email: "", password: "" },
18
+ * validate: (values) => (values.email === "" ? { email: "Required" } : {}),
19
+ * submit: (values) => http.post("/login", values),
20
+ * })
21
+ *
22
+ * Form({
23
+ * form: login,
24
+ * children: [
25
+ * TextField({ bind: bind(login, "email"), label: "Email", type: "email" }),
26
+ * TextField({ bind: bind(login, "password"), label: "Password", type: "password" }),
27
+ * SubmitButton({ form: login, label: "Sign in" }),
28
+ * ],
29
+ * })
30
+ */
31
+
32
+ import type { Form as AuroraForm, FormField } from "@c9up/aurora";
33
+ import { component, html } from "@c9up/aurora";
34
+ import { Button } from "../atoms/Button.js";
35
+ import { Input } from "../atoms/Input.js";
36
+ import { Spinner } from "../atoms/Spinner.js";
37
+ import { Textarea } from "../atoms/Textarea.js";
38
+ import type { Child, Slot } from "../lib/children.js";
39
+ import { slot } from "../lib/children.js";
40
+ import { cn } from "../lib/cn.js";
41
+ import { type Reactive, read } from "../lib/props.js";
42
+ import { Field, fieldIds } from "../molecules/Field.js";
43
+
44
+ /**
45
+ * One field, with a setter that already knows which key it writes to.
46
+ *
47
+ * This shape exists so the input components are not generic over the form.
48
+ * Inside a function generic in `T`, TypeScript cannot prove a `string` is
49
+ * assignable to `T[K]`, and the usual escape is a cast. `bind()` is called at
50
+ * a *concrete* call site, where `K` is a literal and `T[K]` resolves to a real
51
+ * type — so `bind(login, "email")` is a `FieldBinding<string>` and handing it
52
+ * to a numeric input is a compile error rather than a runtime surprise.
53
+ */
54
+ export interface FieldBinding<V> {
55
+ readonly name: string;
56
+ readonly field: FormField<V>;
57
+ set(value: V): void;
58
+ }
59
+
60
+ /** Bind one key of a form. Call it at the field's own call site. */
61
+ export function bind<T, K extends keyof T & string>(
62
+ form: AuroraForm<T>,
63
+ name: K,
64
+ ): FieldBinding<T[K]> {
65
+ return {
66
+ name,
67
+ field: form.field(name),
68
+ set: (value: T[K]) => form.set(name, value),
69
+ };
70
+ }
71
+
72
+ export interface FormProps<T> {
73
+ form: AuroraForm<T>;
74
+ children?: Slot;
75
+ class?: Reactive<string>;
76
+ }
77
+
78
+ /**
79
+ * The `<form>` element itself.
80
+ *
81
+ * `novalidate` on purpose. The browser's own validation bubbles cannot be
82
+ * styled, appear one at a time, and vanish on scroll — and they would fire
83
+ * before the controller's validation ever runs, so the user would see two
84
+ * different error presentations for the same field.
85
+ */
86
+ export function Form<T>(props: FormProps<T>): ReturnType<typeof html> {
87
+ return html`<form
88
+ data-slot="form"
89
+ novalidate
90
+ class="${() => cn("flex flex-col gap-6", read(props.class))}"
91
+ @submit="${(event: Event) => {
92
+ void props.form.handleSubmit(event);
93
+ }}"
94
+ >${slot(props.children)}</form>`;
95
+ }
96
+
97
+ interface FieldShellProps {
98
+ bind: FieldBinding<string>;
99
+ label: Child;
100
+ description?: Child;
101
+ placeholder?: string;
102
+ required?: boolean;
103
+ disabled?: Reactive<boolean>;
104
+ }
105
+
106
+ export interface TextFieldProps extends FieldShellProps {
107
+ type?: string;
108
+ autocomplete?: string;
109
+ }
110
+
111
+ /**
112
+ * A labelled text input bound to one field.
113
+ *
114
+ * The error is shown only once the field has been touched, which is what stops
115
+ * a pristine form opening with "Required" under every empty box.
116
+ *
117
+ * When errors actually appear is Aurora's decision, not this file's, and it is
118
+ * worth knowing: `form()` populates them on `validate()` — which `handleSubmit`
119
+ * calls — and clears a field's error as soon as it is written to. So the
120
+ * sequence is: submit, see every problem at once, and watch each one disappear
121
+ * as it is fixed. Typing into an untouched field does not raise an error, and
122
+ * `markTouched` on blur does not validate; it only lifts the gate for an error
123
+ * that validation has already found.
124
+ */
125
+ export const TextField = component<TextFieldProps>((props) => {
126
+ const ids = fieldIds();
127
+ const { field, set, name } = props.bind;
128
+ const visibleError = (): string | undefined =>
129
+ field.touched() ? (field.error() ?? undefined) : undefined;
130
+
131
+ return Field({
132
+ ids,
133
+ label: props.label,
134
+ description: props.description,
135
+ error: visibleError,
136
+ required: props.required,
137
+ disabled: props.disabled,
138
+ children: Input({
139
+ id: ids.control,
140
+ name,
141
+ type: props.type ?? "text",
142
+ placeholder: props.placeholder,
143
+ autocomplete: props.autocomplete,
144
+ required: props.required,
145
+ disabled: props.disabled,
146
+ invalid: () => visibleError() !== undefined,
147
+ describedBy: ids.describedBy,
148
+ value: () => field.value(),
149
+ onInput: set,
150
+ onBlur: () => field.markTouched(),
151
+ }),
152
+ });
153
+ });
154
+
155
+ export interface TextAreaFieldProps extends FieldShellProps {
156
+ rows?: number;
157
+ }
158
+
159
+ /** The same, with a multi-line control. */
160
+ export const TextAreaField = component<TextAreaFieldProps>((props) => {
161
+ const ids = fieldIds();
162
+ const { field, set, name } = props.bind;
163
+ const visibleError = (): string | undefined =>
164
+ field.touched() ? (field.error() ?? undefined) : undefined;
165
+
166
+ return Field({
167
+ ids,
168
+ label: props.label,
169
+ description: props.description,
170
+ error: visibleError,
171
+ required: props.required,
172
+ disabled: props.disabled,
173
+ children: Textarea({
174
+ id: ids.control,
175
+ name,
176
+ rows: props.rows,
177
+ placeholder: props.placeholder,
178
+ required: props.required,
179
+ disabled: props.disabled,
180
+ invalid: () => visibleError() !== undefined,
181
+ describedBy: ids.describedBy,
182
+ value: () => field.value(),
183
+ onInput: set,
184
+ onBlur: () => field.markTouched(),
185
+ }),
186
+ });
187
+ });
188
+
189
+ export interface SubmitButtonProps<T> {
190
+ form: AuroraForm<T>;
191
+ label: Child;
192
+ /** Replaces the label while the submit is in flight. */
193
+ pendingLabel?: Child;
194
+ class?: Reactive<string>;
195
+ }
196
+
197
+ /**
198
+ * A submit button that disables itself while the submit is in flight.
199
+ *
200
+ * Disabled on `submitting` only — never on `!valid`. A submit button that is
201
+ * dead until the form is correct gives the user nothing to press and no
202
+ * explanation; pressing it and seeing every error at once is how a form tells
203
+ * you what is wrong.
204
+ */
205
+ export function SubmitButton<T>(
206
+ props: SubmitButtonProps<T>,
207
+ ): ReturnType<typeof html> {
208
+ return Button({
209
+ type: "submit",
210
+ disabled: () => props.form.submitting(),
211
+ class: props.class,
212
+ children: () =>
213
+ props.form.submitting()
214
+ ? [Spinner({ class: "mr-2" }), props.pendingLabel ?? props.label]
215
+ : props.label,
216
+ });
217
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * HoverCard — a rich preview shown on hover.
3
+ *
4
+ * A Tooltip carries a phrase; a HoverCard carries content — an avatar, a
5
+ * summary, a link. That difference drives every behavioural difference between
6
+ * the two.
7
+ *
8
+ * It opens more slowly, because the payload is heavier and a card flashing
9
+ * past is worse than a tooltip flashing past. It closes more slowly, because
10
+ * the user is expected to move the pointer *into* it. And it is never the only
11
+ * route to what it contains: pointer-only content is unreachable by keyboard
12
+ * and touch, so a hover card must preview something a click already reaches.
13
+ *
14
+ * `role="dialog"` rather than `tooltip`, since it holds interactive content —
15
+ * a tooltip's contents are announced as a description, which is wrong for
16
+ * something containing a link.
17
+ */
18
+
19
+ import { component, html, onUnmount, signal } from "@c9up/aurora";
20
+ import { type Slot, slot } from "../lib/children.js";
21
+ import { cn } from "../lib/cn.js";
22
+ import { uid } from "../lib/id.js";
23
+ import { zoomInOut } from "../lib/motion.js";
24
+ import { type Reactive, read } from "../lib/props.js";
25
+ import type { Placement } from "../primitives/floating.js";
26
+ import { floatingSurface } from "../primitives/floatingSurface.js";
27
+
28
+ const OPEN_DELAY_MS = 700;
29
+ const CLOSE_DELAY_MS = 300;
30
+
31
+ export interface HoverCardProps {
32
+ trigger?: Slot;
33
+ children?: Slot;
34
+ placement?: Placement;
35
+ openDelay?: number;
36
+ closeDelay?: number;
37
+ class?: Reactive<string>;
38
+ contentClass?: Reactive<string>;
39
+ }
40
+
41
+ export const HoverCard = component<HoverCardProps>((props) => {
42
+ const triggerId = uid("hover-card-trigger");
43
+ const contentId = uid("hover-card-content");
44
+ const open = signal(false);
45
+
46
+ let timer: ReturnType<typeof setTimeout> | undefined;
47
+
48
+ function clear(): void {
49
+ if (timer !== undefined) clearTimeout(timer);
50
+ timer = undefined;
51
+ }
52
+
53
+ function schedule(next: boolean, delay: number): void {
54
+ clear();
55
+ timer = setTimeout(() => open(next), delay);
56
+ }
57
+
58
+ onUnmount(clear);
59
+
60
+ floatingSurface({
61
+ anchor: () => document.getElementById(triggerId),
62
+ open: () => open(),
63
+ onClose: () => {
64
+ clear();
65
+ open(false);
66
+ },
67
+ placement: props.placement ?? "bottom",
68
+ offset: 8,
69
+ content: () =>
70
+ html`<div
71
+ data-slot="hover-card-content"
72
+ id="${contentId}"
73
+ role="dialog"
74
+ aria-labelledby="${triggerId}"
75
+ class="${cn(
76
+ "bg-popover text-popover-foreground z-50 w-64 rounded-md border p-4 shadow-md outline-none",
77
+ zoomInOut,
78
+ read(props.contentClass),
79
+ )}"
80
+ @pointerenter="${clear}"
81
+ @pointerleave="${() => schedule(false, props.closeDelay ?? CLOSE_DELAY_MS)}"
82
+ >${slot(props.children)}</div>`,
83
+ });
84
+
85
+ return html`<span
86
+ data-slot="hover-card-trigger"
87
+ id="${triggerId}"
88
+ class="${() => cn("inline-flex", read(props.class))}"
89
+ @pointerenter="${() => schedule(true, props.openDelay ?? OPEN_DELAY_MS)}"
90
+ @pointerleave="${() => schedule(false, props.closeDelay ?? CLOSE_DELAY_MS)}"
91
+ >${slot(props.trigger)}</span>`;
92
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Menubar — an application menu bar.
3
+ *
4
+ * A row of menu buttons where at most one menu is open. The behaviour that
5
+ * makes it a menubar rather than a row of independent dropdowns is the
6
+ * handover: once a menu is open, moving the pointer onto a neighbouring button
7
+ * switches to it without a click, and ArrowLeft/ArrowRight do the same from
8
+ * the keyboard. That is how every desktop menu bar behaves, and a row of
9
+ * dropdowns that each need their own click is the thing users notice.
10
+ *
11
+ * One `floatingSurface` for the bar, not one per menu. The open menu is an
12
+ * index, so switching moves the same surface to a new anchor instead of
13
+ * closing one and opening another — which would flash, and would lose the
14
+ * exit animation into the entry animation.
15
+ */
16
+
17
+ import { component, html, signal } from "@c9up/aurora";
18
+ import type { Child } from "../lib/children.js";
19
+ import { cn } from "../lib/cn.js";
20
+ import { uid } from "../lib/id.js";
21
+ import { type Reactive, read } from "../lib/props.js";
22
+ import { floatingSurface } from "../primitives/floatingSurface.js";
23
+ import { type MenuEntry, menuPanel, wireMenu } from "../primitives/menu.js";
24
+
25
+ export interface MenubarMenu {
26
+ label: Child;
27
+ entries: readonly MenuEntry[];
28
+ disabled?: boolean;
29
+ }
30
+
31
+ export interface MenubarProps {
32
+ menus: readonly MenubarMenu[];
33
+ class?: Reactive<string>;
34
+ }
35
+
36
+ export const Menubar = component<MenubarProps>((props) => {
37
+ const contentId = uid("menubar-content");
38
+ const triggerIds = props.menus.map(() => uid("menubar-trigger"));
39
+ /** Index of the open menu, or `-1`. */
40
+ const openIndex = signal(-1);
41
+
42
+ let unwire: (() => void) | null = null;
43
+ let focusFirstOnOpen = false;
44
+
45
+ function close(): void {
46
+ openIndex(-1);
47
+ }
48
+
49
+ function openAt(index: number, withKeyboard: boolean): void {
50
+ if (props.menus[index]?.disabled === true) return;
51
+ focusFirstOnOpen = withKeyboard;
52
+ openIndex(index);
53
+ }
54
+
55
+ function move(delta: number): void {
56
+ const count = props.menus.length;
57
+ if (count === 0) return;
58
+ const from = openIndex();
59
+ if (from === -1) return;
60
+ openAt((from + delta + count) % count, true);
61
+ }
62
+
63
+ floatingSurface({
64
+ anchor: () => {
65
+ const id = triggerIds[openIndex()];
66
+ return id === undefined ? null : document.getElementById(id);
67
+ },
68
+ open: () => openIndex() !== -1,
69
+ onClose: close,
70
+ placement: "bottom-start",
71
+ offset: 4,
72
+ content: () =>
73
+ menuPanel({
74
+ id: contentId,
75
+ entries: props.menus[openIndex()]?.entries ?? [],
76
+ onCloseAll: close,
77
+ labelledBy: triggerIds[openIndex()],
78
+ }),
79
+ onOpened: (panel) => {
80
+ const enterItems = focusFirstOnOpen;
81
+ focusFirstOnOpen = false;
82
+ unwire = wireMenu(panel, {
83
+ onCloseAll: close,
84
+ autoFocusFirst: enterItems,
85
+ });
86
+ // Only when no item took focus. Focusing the panel unconditionally
87
+ // steals focus back from the item `autoFocusFirst` just landed on, and
88
+ // opening a menu with the keyboard would never reach its first entry.
89
+ if (!enterItems) panel.focus({ preventScroll: true });
90
+ // Left/right walk the bar from inside the open panel. `menu.js` claims
91
+ // ArrowLeft for closing a submenu and stops propagation when it does,
92
+ // so this only fires at the top level.
93
+ panel.addEventListener("keydown", onPanelKeyDown);
94
+ },
95
+ onClosed: () => {
96
+ unwire?.();
97
+ unwire = null;
98
+ },
99
+ });
100
+
101
+ function onPanelKeyDown(event: KeyboardEvent): void {
102
+ if (event.key === "ArrowRight") {
103
+ event.preventDefault();
104
+ move(1);
105
+ } else if (event.key === "ArrowLeft") {
106
+ event.preventDefault();
107
+ move(-1);
108
+ }
109
+ }
110
+
111
+ return html`<div
112
+ data-slot="menubar"
113
+ role="menubar"
114
+ class="${() =>
115
+ cn(
116
+ "bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
117
+ read(props.class),
118
+ )}"
119
+ >
120
+ ${props.menus.map((menu, index) => renderTrigger(menu, index))}
121
+ </div>`;
122
+
123
+ function renderTrigger(menu: MenubarMenu, index: number): Child {
124
+ return html`<button
125
+ type="button"
126
+ role="menuitem"
127
+ data-slot="menubar-trigger"
128
+ id="${triggerIds[index]}"
129
+ aria-haspopup="menu"
130
+ aria-expanded="${() => (openIndex() === index ? "true" : "false")}"
131
+ aria-controls="${() => (openIndex() === index ? contentId : undefined)}"
132
+ data-state="${() => (openIndex() === index ? "open" : "closed")}"
133
+ ?disabled="${menu.disabled === true}"
134
+ class="focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex select-none items-center rounded-sm px-2 py-1 text-sm font-medium outline-none disabled:pointer-events-none disabled:opacity-50"
135
+ @click="${() => (openIndex() === index ? close() : openAt(index, false))}"
136
+ @pointerenter="${() => {
137
+ // Hover switches menus only while one is already open — otherwise
138
+ // the bar would spring menus at a pointer merely passing over it.
139
+ if (openIndex() !== -1) openAt(index, false);
140
+ }}"
141
+ @keydown="${(event: KeyboardEvent) => {
142
+ if (
143
+ event.key === "ArrowDown" ||
144
+ event.key === "Enter" ||
145
+ event.key === " "
146
+ ) {
147
+ event.preventDefault();
148
+ openAt(index, true);
149
+ } else if (event.key === "ArrowRight") {
150
+ event.preventDefault();
151
+ focusTrigger(index + 1);
152
+ } else if (event.key === "ArrowLeft") {
153
+ event.preventDefault();
154
+ focusTrigger(index - 1);
155
+ }
156
+ }}"
157
+ >${menu.label}</button>`;
158
+ }
159
+
160
+ function focusTrigger(index: number): void {
161
+ const count = props.menus.length;
162
+ if (count === 0) return;
163
+ const id = triggerIds[(index + count) % count];
164
+ if (id === undefined) return;
165
+ document.getElementById(id)?.focus({ preventScroll: true });
166
+ }
167
+ });