@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,61 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Portal — mount a template outside the component's own DOM position.
4
+ *
5
+ * Overlays have to escape their parent. A Popover rendered where it is
6
+ * declared inherits every `overflow: hidden`, `transform` and `z-index` stack
7
+ * between it and the root, and any one of those will clip it or bury it. The
8
+ * fix is universal: render into `<body>` and position with fixed coordinates.
9
+ *
10
+ * Aurora's `render()` already mounts a `TemplateResult` anywhere and hands
11
+ * back a disposer, so a portal is that call plus a host element to hold the
12
+ * content and the discipline to tear both down together.
13
+ */
14
+
15
+ import { render, type TemplateResult } from "@c9up/aurora";
16
+
17
+ export interface PortalOptions {
18
+ /** Where to mount. Defaults to `document.body`. */
19
+ container?: () => Element | null;
20
+ /** Extra classes for the host element. */
21
+ class?: string;
22
+ }
23
+
24
+ export interface Portal {
25
+ /** The element holding the portalled content. */
26
+ readonly host: HTMLElement;
27
+ /** Unmount the content and remove the host. Safe to call twice. */
28
+ close(): void;
29
+ }
30
+
31
+ /**
32
+ * Mount `content` into a fresh host element appended to the container.
33
+ *
34
+ * A host element rather than mounting straight into `<body>`: it gives the
35
+ * content one node to be removed as a unit, keeps sibling portals from
36
+ * interleaving, and gives the layer a place to hang `data-nebula-portal` so a
37
+ * test — or a developer in the inspector — can see what put it there.
38
+ */
39
+ export function portal(
40
+ content: TemplateResult,
41
+ options: PortalOptions = {},
42
+ ): Portal {
43
+ const container = options.container?.() ?? document.body;
44
+ const host = document.createElement("div");
45
+ host.setAttribute("data-nebula-portal", "");
46
+ if (options.class !== undefined) host.className = options.class;
47
+ container.appendChild(host);
48
+
49
+ const dispose = render(content, host);
50
+
51
+ let closed = false;
52
+ return {
53
+ host,
54
+ close(): void {
55
+ if (closed) return;
56
+ closed = true;
57
+ dispose();
58
+ host.remove();
59
+ },
60
+ };
61
+ }
@@ -0,0 +1,185 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Presence — keep a node mounted until its exit animation has finished.
4
+ *
5
+ * Closing an overlay by removing it from the DOM cancels any exit animation:
6
+ * the node is gone before the first frame plays. Radix solves this with a
7
+ * `Presence` wrapper, and shadcn's markup depends on it — the `data-[state=
8
+ * closed]:animate-out` utilities on every overlay assume something is holding
9
+ * the node on screen while that animation runs.
10
+ *
11
+ * `presence()` owns a `state` signal that leads the DOM: it flips to `closed`
12
+ * immediately so the exit animation can start, and only reports `mounted`
13
+ * false once the animation ends. A surface with no animation unmounts on the
14
+ * next tick, so the abstraction costs nothing when unused.
15
+ */
16
+
17
+ import { type ReadSignal, signal } from "@c9up/aurora";
18
+
19
+ export type PresenceState = "open" | "closed";
20
+
21
+ export interface Presence {
22
+ /** Should the node be in the DOM right now? */
23
+ readonly mounted: ReadSignal<boolean>;
24
+ /** The value for `data-state` — drives the enter/exit utility classes. */
25
+ readonly state: ReadSignal<PresenceState>;
26
+ /** Show the node. Cancels a pending unmount. */
27
+ open(): void;
28
+ /** Start the exit animation. Unmount follows when it finishes. */
29
+ close(): void;
30
+ /**
31
+ * Hand over the element once it is live. Presence watches it for the end of
32
+ * the exit animation; until it is given one, close() unmounts immediately.
33
+ */
34
+ attach(element: HTMLElement | null): void;
35
+ /** Drop listeners and timers. */
36
+ dispose(): void;
37
+ }
38
+
39
+ export function presence(initiallyOpen = false): Presence {
40
+ const mounted = signal(initiallyOpen);
41
+ const state = signal<PresenceState>(initiallyOpen ? "open" : "closed");
42
+
43
+ let element: HTMLElement | null = null;
44
+ let pendingUnmount = false;
45
+
46
+ function finishClose(): void {
47
+ if (!pendingUnmount) return;
48
+ pendingUnmount = false;
49
+ mounted(false);
50
+ }
51
+
52
+ /**
53
+ * The animation is only ours if it played on the surface itself.
54
+ *
55
+ * Events bubble, so a child's animation would otherwise unmount the parent
56
+ * mid-flight — a spinner inside a closing dialog is enough to trigger it.
57
+ */
58
+ function onAnimationEnd(event: AnimationEvent | TransitionEvent): void {
59
+ if (event.target !== element) return;
60
+ finishClose();
61
+ }
62
+
63
+ function detach(): void {
64
+ if (element === null) return;
65
+ element.removeEventListener("animationend", onAnimationEnd);
66
+ element.removeEventListener("animationcancel", onAnimationEnd);
67
+ element.removeEventListener("transitionend", onAnimationEnd);
68
+ element.removeEventListener("transitioncancel", onAnimationEnd);
69
+ element = null;
70
+ }
71
+
72
+ return {
73
+ mounted,
74
+ state,
75
+
76
+ open(): void {
77
+ pendingUnmount = false;
78
+ mounted(true);
79
+ state("open");
80
+ },
81
+
82
+ close(): void {
83
+ if (!mounted()) return;
84
+ state("closed");
85
+
86
+ // No element yet means nothing can be animating — unmount now rather
87
+ // than waiting for an event that will never arrive.
88
+ if (element === null) {
89
+ mounted(false);
90
+ return;
91
+ }
92
+
93
+ pendingUnmount = true;
94
+ if (!isAnimating(element)) finishClose();
95
+ },
96
+
97
+ attach(next: HTMLElement | null): void {
98
+ detach();
99
+ element = next;
100
+ if (element === null) return;
101
+ element.addEventListener("animationend", onAnimationEnd);
102
+ element.addEventListener("animationcancel", onAnimationEnd);
103
+ element.addEventListener("transitionend", onAnimationEnd);
104
+ element.addEventListener("transitioncancel", onAnimationEnd);
105
+ },
106
+
107
+ dispose(): void {
108
+ pendingUnmount = false;
109
+ detach();
110
+ },
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Run `done` once the element's exit animation finishes, or immediately when
116
+ * there is none. Returns a cancel function.
117
+ *
118
+ * The portalled surfaces do not use `presence()` — they own their own mounting
119
+ * through `portal()`, so a second `mounted` signal would just be a shadow of
120
+ * the portal's own lifetime. What they do need is this: the answer to "may I
121
+ * remove the node yet". Call it *after* flipping `data-state` to `closed`, so
122
+ * the computed style already reflects the closing rules.
123
+ */
124
+ export function onExitFinished(
125
+ element: HTMLElement,
126
+ done: () => void,
127
+ ): () => void {
128
+ if (!isAnimating(element)) {
129
+ done();
130
+ return () => {};
131
+ }
132
+
133
+ function finish(event: AnimationEvent | TransitionEvent): void {
134
+ // Bubbled events from children would cut the parent's exit short.
135
+ if (event.target !== element) return;
136
+ cancel();
137
+ done();
138
+ }
139
+
140
+ function cancel(): void {
141
+ element.removeEventListener("animationend", finish);
142
+ element.removeEventListener("animationcancel", finish);
143
+ element.removeEventListener("transitionend", finish);
144
+ element.removeEventListener("transitioncancel", finish);
145
+ }
146
+
147
+ element.addEventListener("animationend", finish);
148
+ element.addEventListener("animationcancel", finish);
149
+ element.addEventListener("transitionend", finish);
150
+ element.addEventListener("transitioncancel", finish);
151
+ return cancel;
152
+ }
153
+
154
+ /**
155
+ * Is an exit animation actually running?
156
+ *
157
+ * Read after `data-state` has flipped, so the computed style already reflects
158
+ * the closing rules. `animationName: none` and a zero transition duration both
159
+ * mean there is nothing to wait for, and waiting anyway would strand the node
160
+ * in the DOM forever — the failure mode this check exists to prevent.
161
+ */
162
+ function isAnimating(element: HTMLElement): boolean {
163
+ if (typeof getComputedStyle !== "function") return false;
164
+
165
+ const style = getComputedStyle(element);
166
+ const hasAnimation =
167
+ style.animationName !== "" && style.animationName !== "none";
168
+ if (hasAnimation) return true;
169
+
170
+ return parseDuration(style.transitionDuration) > 0;
171
+ }
172
+
173
+ /** Longest duration in a comma-separated CSS time list, in milliseconds. */
174
+ function parseDuration(value: string): number {
175
+ let longest = 0;
176
+ for (const part of value.split(",")) {
177
+ const trimmed = part.trim();
178
+ if (trimmed === "") continue;
179
+ const numeric = Number.parseFloat(trimmed);
180
+ if (Number.isNaN(numeric)) continue;
181
+ const ms = trimmed.endsWith("ms") ? numeric : numeric * 1000;
182
+ if (ms > longest) longest = ms;
183
+ }
184
+ return longest;
185
+ }
@@ -0,0 +1,212 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Roving focus — arrow-key navigation over a group of items.
4
+ *
5
+ * The WAI-ARIA composite-widget pattern: a menu, listbox, tab list or toolbar
6
+ * is *one* tab stop, and arrows move within it. Exactly one item carries
7
+ * `tabindex="0"` at a time; the rest are `-1`. That is what stops a 40-item
8
+ * menu from swallowing 40 presses of Tab.
9
+ *
10
+ * Used by DropdownMenu, ContextMenu, Menubar, Select, Combobox, Command, Tabs
11
+ * and ToggleGroup. The differences between them are configuration, not
12
+ * behaviour: orientation, whether the ends wrap, and whether moving focus also
13
+ * selects.
14
+ *
15
+ * Items are found by querying the container on each keypress rather than being
16
+ * registered up front. Menu contents are reactive — filtered by a Command
17
+ * input, grown by a loaded page — and a registry captured at mount goes stale
18
+ * the moment they change. The query costs a few microseconds per keypress,
19
+ * which is not a budget worth optimising against correctness.
20
+ */
21
+
22
+ import { focusSilently, isVisible } from "./focusable.js";
23
+
24
+ export type Orientation = "vertical" | "horizontal" | "both";
25
+
26
+ export interface RovingFocusOptions {
27
+ /** The group element. Read lazily — it mounts after the group registers. */
28
+ container: () => HTMLElement | null;
29
+ /** Which descendants are navigable. Default: nebula's own item marker. */
30
+ itemSelector?: string;
31
+ /** Which arrows move focus. Default `"vertical"`. */
32
+ orientation?: Orientation;
33
+ /** Wrap from last to first and back. Default `true`. */
34
+ loop?: boolean;
35
+ /** Enter / Space on the focused item. */
36
+ onSelect?: (item: HTMLElement, event: KeyboardEvent) => void;
37
+ /** Focus moved to a new item — used by menus that highlight on navigate. */
38
+ onFocusChange?: (item: HTMLElement) => void;
39
+ }
40
+
41
+ export interface RovingFocus {
42
+ /** Focus the first enabled item. */
43
+ focusFirst(): void;
44
+ /** Focus the last enabled item. */
45
+ focusLast(): void;
46
+ /** The navigable items, in document order. */
47
+ items(): HTMLElement[];
48
+ /** Re-apply `tabindex` after the item list changes. */
49
+ sync(): void;
50
+ /** Detach the key handler. Safe to call twice. */
51
+ destroy(): void;
52
+ }
53
+
54
+ const DEFAULT_ITEM_SELECTOR = "[data-nebula-item]";
55
+
56
+ export function rovingFocus(options: RovingFocusOptions): RovingFocus {
57
+ const selector = options.itemSelector ?? DEFAULT_ITEM_SELECTOR;
58
+ const orientation = options.orientation ?? "vertical";
59
+ const loop = options.loop !== false;
60
+
61
+ function items(): HTMLElement[] {
62
+ const container = options.container();
63
+ if (container === null) return [];
64
+
65
+ const found: HTMLElement[] = [];
66
+ for (const node of container.querySelectorAll(selector)) {
67
+ if (!(node instanceof HTMLElement)) continue;
68
+ // Disabled items stay in the DOM for layout and screen-reader context
69
+ // but must not be landed on — matching how a disabled menu item behaves
70
+ // everywhere else.
71
+ if (node.hasAttribute("data-disabled")) continue;
72
+ if (node.getAttribute("aria-disabled") === "true") continue;
73
+ if (!isVisible(node)) continue;
74
+ found.push(node);
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /**
80
+ * One item at `tabindex="0"`, everything else at `-1`.
81
+ *
82
+ * The active one is whichever currently holds focus, falling back to the
83
+ * first. The fallback is what makes the group re-enterable by Tab after
84
+ * focus has left it.
85
+ */
86
+ function sync(): void {
87
+ const list = items();
88
+ const focused = document.activeElement;
89
+ const activeIndex =
90
+ focused instanceof HTMLElement ? list.indexOf(focused) : -1;
91
+ const active = activeIndex === -1 ? 0 : activeIndex;
92
+
93
+ list.forEach((item, index) => {
94
+ item.tabIndex = index === active ? 0 : -1;
95
+ });
96
+ }
97
+
98
+ function move(delta: number): void {
99
+ const list = items();
100
+ if (list.length === 0) return;
101
+
102
+ const focused = document.activeElement;
103
+ const current = focused instanceof HTMLElement ? list.indexOf(focused) : -1;
104
+ const next = step(current, delta, list.length, loop);
105
+ if (next === null) return;
106
+
107
+ const target = list[next];
108
+ if (target === undefined) return;
109
+
110
+ for (const item of list) item.tabIndex = -1;
111
+ target.tabIndex = 0;
112
+ focusSilently(target);
113
+ options.onFocusChange?.(target);
114
+ }
115
+
116
+ function focusEdge(edge: "first" | "last"): void {
117
+ const list = items();
118
+ const target = edge === "first" ? list[0] : list[list.length - 1];
119
+ if (target === undefined) return;
120
+
121
+ for (const item of list) item.tabIndex = -1;
122
+ target.tabIndex = 0;
123
+ focusSilently(target);
124
+ options.onFocusChange?.(target);
125
+ }
126
+
127
+ function onKeyDown(event: KeyboardEvent): void {
128
+ const container = options.container();
129
+ if (container === null) return;
130
+ if (event.target instanceof Node && !container.contains(event.target))
131
+ return;
132
+ // A modifier means the user is talking to the browser, not the widget.
133
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
134
+
135
+ const direction = directionFor(event.key, orientation);
136
+ if (direction !== 0) {
137
+ event.preventDefault();
138
+ move(direction);
139
+ return;
140
+ }
141
+
142
+ if (event.key === "Home") {
143
+ event.preventDefault();
144
+ focusEdge("first");
145
+ return;
146
+ }
147
+ if (event.key === "End") {
148
+ event.preventDefault();
149
+ focusEdge("last");
150
+ return;
151
+ }
152
+
153
+ if (event.key === "Enter" || event.key === " ") {
154
+ const active = document.activeElement;
155
+ if (!(active instanceof HTMLElement)) return;
156
+ if (!items().includes(active)) return;
157
+ // Space would scroll the page and Enter would submit a surrounding form;
158
+ // inside a composite widget both mean "activate this item".
159
+ event.preventDefault();
160
+ options.onSelect?.(active, event);
161
+ }
162
+ }
163
+
164
+ document.addEventListener("keydown", onKeyDown, true);
165
+
166
+ let destroyed = false;
167
+ return {
168
+ focusFirst: () => focusEdge("first"),
169
+ focusLast: () => focusEdge("last"),
170
+ items,
171
+ sync,
172
+ destroy(): void {
173
+ if (destroyed) return;
174
+ destroyed = true;
175
+ document.removeEventListener("keydown", onKeyDown, true);
176
+ },
177
+ };
178
+ }
179
+
180
+ /** `-1` for previous, `+1` for next, `0` when the key is not ours. */
181
+ function directionFor(key: string, orientation: Orientation): number {
182
+ const vertical = orientation === "vertical" || orientation === "both";
183
+ const horizontal = orientation === "horizontal" || orientation === "both";
184
+
185
+ if (vertical && key === "ArrowDown") return 1;
186
+ if (vertical && key === "ArrowUp") return -1;
187
+ if (horizontal && key === "ArrowRight") return 1;
188
+ if (horizontal && key === "ArrowLeft") return -1;
189
+ return 0;
190
+ }
191
+
192
+ /**
193
+ * Next index, or `null` when the move is refused at an unlooped end.
194
+ *
195
+ * `current === -1` means focus is not on an item yet — the first arrow press
196
+ * after opening a menu. It enters at the first item going down and the last
197
+ * going up, which is what makes ArrowUp on a fresh menu land on its bottom
198
+ * entry the way every native menu does.
199
+ */
200
+ function step(
201
+ current: number,
202
+ delta: number,
203
+ length: number,
204
+ loop: boolean,
205
+ ): number | null {
206
+ if (current === -1) return delta > 0 ? 0 : length - 1;
207
+
208
+ const next = current + delta;
209
+ if (next >= 0 && next < length) return next;
210
+ if (!loop) return null;
211
+ return next < 0 ? length - 1 : 0;
212
+ }
@@ -0,0 +1,79 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Body scroll lock — stop the page scrolling behind a modal surface.
4
+ *
5
+ * Two details separate a working lock from the naive `overflow: hidden`:
6
+ *
7
+ * 1. **Reference counting.** Two modals can be open at once (a confirm dialog
8
+ * over a sheet). The second lock must not re-read the "original" overflow,
9
+ * which is by then already `hidden`, and the first release must not unlock
10
+ * while the second is still up.
11
+ * 2. **Scrollbar compensation.** Hiding overflow removes the scrollbar, the
12
+ * viewport widens by its width, and the whole page jumps sideways as the
13
+ * modal opens. Padding the body by exactly that width holds it still.
14
+ *
15
+ * The saved styles are the *inline* ones, not the computed ones. Restoring a
16
+ * computed value would write a hardcoded padding onto a body that had none,
17
+ * and the layout would stay shifted after the last modal closed.
18
+ */
19
+
20
+ let depth = 0;
21
+ let savedOverflow = "";
22
+ let savedPaddingRight = "";
23
+
24
+ /** Lock page scrolling. Returns the matching unlock; safe to call twice. */
25
+ export function lockScroll(): () => void {
26
+ if (typeof document === "undefined") return () => {};
27
+
28
+ depth += 1;
29
+ if (depth === 1) applyLock();
30
+
31
+ let released = false;
32
+ return (): void => {
33
+ if (released) return;
34
+ released = true;
35
+ depth -= 1;
36
+ if (depth === 0) removeLock();
37
+ };
38
+ }
39
+
40
+ /** Is the page currently locked? Exposed for tests. */
41
+ export function isScrollLocked(): boolean {
42
+ return depth > 0;
43
+ }
44
+
45
+ function applyLock(): void {
46
+ const body = document.body;
47
+ savedOverflow = body.style.overflow;
48
+ savedPaddingRight = body.style.paddingRight;
49
+
50
+ const gap = scrollbarWidth();
51
+ if (gap > 0) {
52
+ // Add to whatever padding the body already has rather than replacing it.
53
+ const existing = Number.parseFloat(getComputedStyle(body).paddingRight);
54
+ const base = Number.isNaN(existing) ? 0 : existing;
55
+ body.style.paddingRight = `${base + gap}px`;
56
+ }
57
+ body.style.overflow = "hidden";
58
+ }
59
+
60
+ function removeLock(): void {
61
+ const body = document.body;
62
+ body.style.overflow = savedOverflow;
63
+ body.style.paddingRight = savedPaddingRight;
64
+ savedOverflow = "";
65
+ savedPaddingRight = "";
66
+ }
67
+
68
+ /**
69
+ * Width of the vertical scrollbar, or 0 when it overlays the content.
70
+ *
71
+ * `innerWidth - documentElement.clientWidth` measures the real gutter, which
72
+ * is 0 on macOS overlay scrollbars and on touch devices. Measuring it beats
73
+ * assuming a value, because assuming produces a *reverse* jump on exactly the
74
+ * platforms where there is no scrollbar to compensate for.
75
+ */
76
+ function scrollbarWidth(): number {
77
+ if (typeof window === "undefined") return 0;
78
+ return Math.max(0, window.innerWidth - document.documentElement.clientWidth);
79
+ }
@@ -0,0 +1,156 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Type-ahead — jump to an item by typing its first letters.
4
+ *
5
+ * Native `<select>` does it, so a listbox or menu that does not feels broken.
6
+ * The WAI-ARIA practices call for it on listbox, menu, tree and combobox.
7
+ *
8
+ * Two rules carry the whole behaviour, and both come from the native control:
9
+ *
10
+ * - Keys typed within the timeout build one search string, so "st" reaches
11
+ * "Stockholm" rather than stopping at anything beginning with "t".
12
+ * - Repeating a single letter cycles through the items starting with it, which
13
+ * is how you reach the third "S" entry without knowing the rest of its name.
14
+ *
15
+ * Matching starts *after* the currently focused item and wraps, so a match
16
+ * always moves forward — otherwise every search would snap back to the first
17
+ * hit in the list and the cycling rule could never take effect.
18
+ */
19
+
20
+ const RESET_AFTER_MS = 1000;
21
+
22
+ export interface TypeaheadOptions {
23
+ /** Current candidates, in display order. Re-read on every keypress. */
24
+ items: () => readonly HTMLElement[];
25
+ /** The item to search from. Defaults to whatever has focus. */
26
+ current?: () => HTMLElement | null;
27
+ /** A match was found. */
28
+ onMatch: (item: HTMLElement) => void;
29
+ /** Text to match against. Defaults to the item's trimmed text content. */
30
+ textOf?: (item: HTMLElement) => string;
31
+ /** How long keys keep accumulating. Default `1000`ms. */
32
+ resetAfter?: number;
33
+ }
34
+
35
+ export interface Typeahead {
36
+ /**
37
+ * Feed a keydown in. Returns `true` when the key was consumed as search
38
+ * input, so the caller can skip its own handling for that press.
39
+ */
40
+ handleKey(event: KeyboardEvent): boolean;
41
+ /** Drop the buffer — call when the surface closes. */
42
+ reset(): void;
43
+ }
44
+
45
+ export function typeahead(options: TypeaheadOptions): Typeahead {
46
+ const resetAfter = options.resetAfter ?? RESET_AFTER_MS;
47
+ let buffer = "";
48
+ let timer: ReturnType<typeof setTimeout> | undefined;
49
+
50
+ function reset(): void {
51
+ buffer = "";
52
+ if (timer !== undefined) clearTimeout(timer);
53
+ timer = undefined;
54
+ }
55
+
56
+ function restartTimer(): void {
57
+ if (timer !== undefined) clearTimeout(timer);
58
+ timer = setTimeout(reset, resetAfter);
59
+ }
60
+
61
+ return {
62
+ reset,
63
+
64
+ handleKey(event: KeyboardEvent): boolean {
65
+ if (!isSearchKey(event, buffer.length > 0)) return false;
66
+
67
+ buffer += event.key.toLowerCase();
68
+ restartTimer();
69
+
70
+ const items = options.items();
71
+ if (items.length === 0) return true;
72
+
73
+ // A buffer of one repeated character means "next item starting with it",
74
+ // not "item starting with that string repeated".
75
+ const query = allSameCharacter(buffer) ? (buffer[0] ?? "") : buffer;
76
+ const skipCurrent = allSameCharacter(buffer) && buffer.length > 1;
77
+
78
+ const from = startIndex(items, options.current?.() ?? focusedElement());
79
+ const match = findFrom(items, query, from, skipCurrent, options.textOf);
80
+ if (match !== null) options.onMatch(match);
81
+ return true;
82
+ },
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Is this keypress search input?
88
+ *
89
+ * Single printable characters only. `event.key` is `"a"` for a letter but
90
+ * `"ArrowDown"` or `"Enter"` for a control key, so a length check separates
91
+ * them without a keycode table. Modifiers are excluded because Ctrl+F belongs
92
+ * to the browser — but Shift is not, since it is how you type a capital.
93
+ *
94
+ * Space is the one character that depends on context. On an empty buffer it
95
+ * means "activate the focused item" and must reach the caller untouched; once
96
+ * a search is under way it is a word separator, and swallowing it is what lets
97
+ * "new y" reach "New York".
98
+ */
99
+ function isSearchKey(event: KeyboardEvent, hasBuffer: boolean): boolean {
100
+ if (event.ctrlKey || event.metaKey || event.altKey) return false;
101
+ if (event.key.length !== 1) return false;
102
+ if (event.key === " ") return hasBuffer;
103
+ return true;
104
+ }
105
+
106
+ function allSameCharacter(value: string): boolean {
107
+ if (value.length < 2) return true;
108
+ const first = value[0];
109
+ for (const character of value) {
110
+ if (character !== first) return false;
111
+ }
112
+ return true;
113
+ }
114
+
115
+ function focusedElement(): HTMLElement | null {
116
+ if (typeof document === "undefined") return null;
117
+ const active = document.activeElement;
118
+ return active instanceof HTMLElement ? active : null;
119
+ }
120
+
121
+ function startIndex(
122
+ items: readonly HTMLElement[],
123
+ current: HTMLElement | null,
124
+ ): number {
125
+ if (current === null) return 0;
126
+ const index = items.indexOf(current);
127
+ return index === -1 ? 0 : index;
128
+ }
129
+
130
+ /**
131
+ * First item matching `query`, searching forward from `from` and wrapping.
132
+ *
133
+ * `skipCurrent` is the cycling rule: repeating a letter must advance past the
134
+ * item already selected. A fresh search keeps the current item eligible, so
135
+ * typing the first letters of what is already highlighted does not jump away.
136
+ */
137
+ function findFrom(
138
+ items: readonly HTMLElement[],
139
+ query: string,
140
+ from: number,
141
+ skipCurrent: boolean,
142
+ textOf?: (item: HTMLElement) => string,
143
+ ): HTMLElement | null {
144
+ if (query === "") return null;
145
+
146
+ const offset = skipCurrent ? 1 : 0;
147
+ for (let i = 0; i < items.length; i += 1) {
148
+ const item = items[(from + i + offset) % items.length];
149
+ if (item === undefined) continue;
150
+ const text = (textOf?.(item) ?? item.textContent ?? "")
151
+ .trim()
152
+ .toLowerCase();
153
+ if (text.startsWith(query)) return item;
154
+ }
155
+ return null;
156
+ }