@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,195 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Dismissable layers — Escape, click-outside and focus-outside, stacked.
4
+ *
5
+ * Every transient surface needs this: Popover, DropdownMenu, ContextMenu,
6
+ * Select, Combobox, Tooltip, Dialog, Sheet, Drawer. Wiring it per component
7
+ * is how you end up with a menu inside a dialog where Escape closes both, or
8
+ * a submenu whose parent collapses the moment you click into it.
9
+ *
10
+ * So layers live in one stack and the rules are applied once, globally:
11
+ *
12
+ * - **Escape** reaches only the topmost layer that accepts it. That is what
13
+ * makes Escape close a submenu first and its parent menu second.
14
+ * - **Pointer outside** walks the stack top-down and stops at the first layer
15
+ * the event landed inside. Layers above it close, layers below it stay. A
16
+ * click in a parent menu therefore closes the submenu and nothing else.
17
+ * - **Focus outside** follows the same walk, but only layers that opt in — a
18
+ * modal dialog holds focus by design and must not close because focus moved.
19
+ *
20
+ * Layers are portalled, so a nested surface is not a DOM descendant of its
21
+ * parent. Containment is answered per layer by its own element plus whatever
22
+ * it excludes (its trigger, almost always), never by tree position.
23
+ */
24
+
25
+ export type DismissReason = "escape" | "outside-pointer" | "outside-focus";
26
+
27
+ export interface DismissableOptions {
28
+ /** The surface itself. Read lazily — it mounts after the layer registers. */
29
+ element: () => HTMLElement | null;
30
+ /** Called when this layer should close. */
31
+ onDismiss: (reason: DismissReason) => void;
32
+ /**
33
+ * Extra elements that do not count as "outside". The trigger belongs here:
34
+ * without it, pressing the trigger to close would dismiss on pointerdown and
35
+ * immediately reopen on click.
36
+ */
37
+ exclude?: () => ReadonlyArray<HTMLElement | null | undefined>;
38
+ /** Escape dismisses this layer when it is topmost. Default `true`. */
39
+ escapeKey?: boolean;
40
+ /** A pointer press outside dismisses. Default `true`. */
41
+ outsidePointer?: boolean;
42
+ /** Focus moving outside dismisses. Default `false`. */
43
+ outsideFocus?: boolean;
44
+ }
45
+
46
+ export interface DismissableLayer {
47
+ /** Remove this layer from the stack. Safe to call twice. */
48
+ remove(): void;
49
+ }
50
+
51
+ interface Layer extends DismissableOptions {
52
+ readonly id: number;
53
+ }
54
+
55
+ const stack: Layer[] = [];
56
+ let nextLayerId = 0;
57
+ let listening = false;
58
+
59
+ /**
60
+ * Register a dismissable layer. The returned handle must be removed when the
61
+ * surface unmounts — a stale layer keeps answering Escape for a surface that
62
+ * is no longer on screen.
63
+ */
64
+ export function dismissable(options: DismissableOptions): DismissableLayer {
65
+ nextLayerId += 1;
66
+ const layer: Layer = { ...options, id: nextLayerId };
67
+ stack.push(layer);
68
+ startListening();
69
+
70
+ let removed = false;
71
+ return {
72
+ remove(): void {
73
+ if (removed) return;
74
+ removed = true;
75
+ const index = stack.findIndex((entry) => entry.id === layer.id);
76
+ if (index !== -1) stack.splice(index, 1);
77
+ stopListeningIfIdle();
78
+ },
79
+ };
80
+ }
81
+
82
+ /** How many layers are currently open. Used by the Escape/scroll-lock tests. */
83
+ export function layerCount(): number {
84
+ return stack.length;
85
+ }
86
+
87
+ // ─── global listeners ────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Listeners attach on the first layer and detach with the last, rather than
91
+ * living for the lifetime of the page. An app that never opens an overlay
92
+ * pays nothing, and a test that opens and closes one leaves no residue.
93
+ */
94
+ function startListening(): void {
95
+ if (listening || typeof document === "undefined") return;
96
+ listening = true;
97
+ document.addEventListener("keydown", onKeyDown, true);
98
+ document.addEventListener("pointerdown", onPointerDown, true);
99
+ document.addEventListener("focusin", onFocusIn, true);
100
+ }
101
+
102
+ function stopListeningIfIdle(): void {
103
+ if (!listening || stack.length > 0) return;
104
+ listening = false;
105
+ document.removeEventListener("keydown", onKeyDown, true);
106
+ document.removeEventListener("pointerdown", onPointerDown, true);
107
+ document.removeEventListener("focusin", onFocusIn, true);
108
+ }
109
+
110
+ function onKeyDown(event: KeyboardEvent): void {
111
+ if (event.key !== "Escape") return;
112
+
113
+ // Topmost layer that accepts Escape — not simply the topmost layer. A
114
+ // Tooltip stacked over a Popover opts out, and Escape must then reach the
115
+ // Popover rather than being swallowed.
116
+ for (let i = stack.length - 1; i >= 0; i -= 1) {
117
+ const layer = stack[i];
118
+ if (layer === undefined) continue;
119
+ if (layer.escapeKey === false) continue;
120
+ event.stopPropagation();
121
+ layer.onDismiss("escape");
122
+ return;
123
+ }
124
+ }
125
+
126
+ function onPointerDown(event: Event): void {
127
+ dismissOutside(event, "outside-pointer");
128
+ }
129
+
130
+ function onFocusIn(event: Event): void {
131
+ dismissOutside(event, "outside-focus");
132
+ }
133
+
134
+ /**
135
+ * Close every layer the event landed outside of, stopping at the first one it
136
+ * landed inside.
137
+ *
138
+ * The snapshot is taken before dismissing: `onDismiss` mutates the stack, and
139
+ * iterating the live array while it shrinks skips layers.
140
+ */
141
+ function dismissOutside(event: Event, reason: DismissReason): void {
142
+ const path = eventPath(event);
143
+ const snapshot = stack.slice();
144
+
145
+ for (let i = snapshot.length - 1; i >= 0; i -= 1) {
146
+ const layer = snapshot[i];
147
+ if (layer === undefined) continue;
148
+ if (containsEvent(layer, path)) return;
149
+
150
+ const optedIn =
151
+ reason === "outside-pointer"
152
+ ? layer.outsidePointer !== false
153
+ : layer.outsideFocus === true;
154
+
155
+ // A layer that opted out still blocks: closing the one beneath it while
156
+ // it stays open would leave the stack inverted on screen.
157
+ if (!optedIn) return;
158
+ layer.onDismiss(reason);
159
+ }
160
+ }
161
+
162
+ /** Did the event originate inside this layer, or inside anything it excludes? */
163
+ function containsEvent(layer: Layer, path: readonly EventTarget[]): boolean {
164
+ const element = layer.element();
165
+ if (element !== null && path.includes(element)) return true;
166
+
167
+ const excluded = layer.exclude?.() ?? [];
168
+ for (const candidate of excluded) {
169
+ if (candidate && path.includes(candidate)) return true;
170
+ }
171
+ return false;
172
+ }
173
+
174
+ /**
175
+ * The nodes an event travelled through.
176
+ *
177
+ * `composedPath()` is what makes containment correct across a shadow root,
178
+ * where `event.target` is retargeted to the host and a naive `contains()`
179
+ * check reports the wrong answer. Not every DOM implementation nebula runs
180
+ * against provides it, so the ancestor walk stands in when it is missing.
181
+ */
182
+ function eventPath(event: Event): readonly EventTarget[] {
183
+ if (typeof event.composedPath === "function") {
184
+ const path = event.composedPath();
185
+ if (path.length > 0) return path;
186
+ }
187
+
188
+ const walked: EventTarget[] = [];
189
+ let node: Node | null = event.target instanceof Node ? event.target : null;
190
+ while (node !== null) {
191
+ walked.push(node);
192
+ node = node.parentNode;
193
+ }
194
+ return walked;
195
+ }
@@ -0,0 +1,453 @@
1
+ /// <reference lib="dom" />
2
+ /**
3
+ * Floating placement — where a popover, menu, select or tooltip goes.
4
+ *
5
+ * This is the piece Radix delegates to `@floating-ui/dom`. nebula computes it
6
+ * instead, because the alternative is a runtime dependency in a workspace
7
+ * whose stated position is that `cn` was worth reimplementing rather than
8
+ * pulling in clsx and tailwind-merge.
9
+ *
10
+ * Scope is chosen to make that defensible. Floating UI is a general middleware
11
+ * pipeline; this is the four behaviours the shadcn component set actually
12
+ * uses — `offset`, `flip`, `shift`, and arrow centring — plus the available
13
+ * height a scrollable menu needs for its `max-height`. No middleware
14
+ * abstraction, no virtual elements, no `autoPlacement`.
15
+ *
16
+ * The geometry lives in `resolvePosition`, a pure function over rectangles.
17
+ * Everything that touches the DOM — measuring, writing styles, watching for
18
+ * scroll — is in `autoPosition` around it. That split is what lets the flip
19
+ * and shift rules be tested exhaustively without a browser, which matters
20
+ * because they are where placement bugs actually live.
21
+ *
22
+ * Coordinates are viewport-relative and meant for `position: fixed`. Fixed
23
+ * positioning is what lets a portalled surface ignore every transformed or
24
+ * clipping ancestor between it and the root; an absolutely positioned popover
25
+ * has to find an offset parent, and gets it wrong the moment one appears.
26
+ */
27
+
28
+ export type Side = "top" | "right" | "bottom" | "left";
29
+ export type Align = "start" | "center" | "end";
30
+ export type Placement = Side | `${Side}-${Align}`;
31
+
32
+ /** Just the fields of a DOMRect the maths needs. */
33
+ export interface Rect {
34
+ readonly x: number;
35
+ readonly y: number;
36
+ readonly width: number;
37
+ readonly height: number;
38
+ }
39
+
40
+ export interface Viewport {
41
+ readonly width: number;
42
+ readonly height: number;
43
+ }
44
+
45
+ export interface PositionOptions {
46
+ /** Preferred placement. Default `"bottom"`. */
47
+ placement?: Placement;
48
+ /** Gap between anchor and surface, in px. Default `4`. */
49
+ offset?: number;
50
+ /** Keep this much clearance from the viewport edge. Default `8`. */
51
+ padding?: number;
52
+ /** Flip to the opposite side when the preferred one does not fit. Default `true`. */
53
+ flip?: boolean;
54
+ /** Slide along the cross axis to stay on screen. Default `true`. */
55
+ shift?: boolean;
56
+ /** Arrow size in px; centres the arrow and keeps it off the corners. */
57
+ arrowSize?: number;
58
+ /**
59
+ * Mirror the placement for a right-to-left writing direction.
60
+ *
61
+ * Placements are written in physical terms — `"right-start"` for a submenu —
62
+ * because that is what reads clearly at the call site and what CSS ends up
63
+ * applying. In Arabic or Hebrew those names mean the wrong edge: a submenu
64
+ * belongs on the *inline end*, which is the left. Rather than making every
65
+ * component reason in logical directions, the mirror happens once, here.
66
+ *
67
+ * `autoPosition` reads it off the anchor's computed direction, so a component
68
+ * never has to pass it.
69
+ */
70
+ rtl?: boolean;
71
+ }
72
+
73
+ export interface Position {
74
+ readonly x: number;
75
+ readonly y: number;
76
+ /** Placement actually used — may differ from the request after a flip. */
77
+ readonly placement: Placement;
78
+ readonly side: Side;
79
+ readonly align: Align;
80
+ /**
81
+ * Space between the surface's edge and the viewport on the resolved side.
82
+ * Menus bind it to `max-height` so a long list scrolls instead of
83
+ * overflowing off screen.
84
+ */
85
+ readonly availableHeight: number;
86
+ /** Arrow offset along the surface's cross axis, when `arrowSize` was given. */
87
+ readonly arrow?: { readonly x?: number; readonly y?: number };
88
+ }
89
+
90
+ const DEFAULT_OFFSET = 4;
91
+ const DEFAULT_PADDING = 8;
92
+
93
+ /**
94
+ * Compute where the surface goes. Pure — no DOM, no side effects.
95
+ *
96
+ * Order matters and matches Floating UI's: offset, then flip, then shift.
97
+ * Shifting before flipping would slide a surface halfway off the anchor to
98
+ * make room on a side that should have been abandoned entirely.
99
+ */
100
+ export function resolvePosition(
101
+ anchor: Rect,
102
+ floating: Rect,
103
+ viewport: Viewport,
104
+ options: PositionOptions = {},
105
+ ): Position {
106
+ const offset = options.offset ?? DEFAULT_OFFSET;
107
+ const padding = options.padding ?? DEFAULT_PADDING;
108
+ const requested = options.placement ?? "bottom";
109
+ const [physicalSide, physicalAlign] = splitPlacement(requested);
110
+ const [requestedSide, align] =
111
+ options.rtl === true
112
+ ? mirrorForRtl(physicalSide, physicalAlign)
113
+ : [physicalSide, physicalAlign];
114
+
115
+ const side =
116
+ options.flip === false
117
+ ? requestedSide
118
+ : bestSide(requestedSide, anchor, floating, viewport, offset, padding);
119
+
120
+ let { x, y } = coordsFor(side, align, anchor, floating, offset);
121
+
122
+ if (options.shift !== false) {
123
+ if (isVertical(side)) {
124
+ x = clamp(x, padding, viewport.width - floating.width - padding);
125
+ } else {
126
+ y = clamp(y, padding, viewport.height - floating.height - padding);
127
+ }
128
+ }
129
+
130
+ const position: Position = {
131
+ x,
132
+ y,
133
+ placement: align === "center" ? side : `${side}-${align}`,
134
+ side,
135
+ align,
136
+ availableHeight: spaceOn(side, anchor, viewport, offset, padding),
137
+ };
138
+
139
+ if (options.arrowSize === undefined) return position;
140
+ return {
141
+ ...position,
142
+ arrow: arrowOffset(
143
+ side,
144
+ anchor,
145
+ floating,
146
+ { x, y },
147
+ options.arrowSize,
148
+ padding,
149
+ ),
150
+ };
151
+ }
152
+
153
+ // ─── geometry ────────────────────────────────────────────────────────
154
+
155
+ function splitPlacement(placement: Placement): [Side, Align] {
156
+ const dash = placement.indexOf("-");
157
+ if (dash === -1) return [toSide(placement), "center"];
158
+ return [toSide(placement.slice(0, dash)), toAlign(placement.slice(dash + 1))];
159
+ }
160
+
161
+ /**
162
+ * Narrow a substring back to `Side` / `Align`.
163
+ *
164
+ * `splitPlacement` slices a string the type system already proved is a valid
165
+ * `Placement`, but slicing erases that. A guard restores it without a cast,
166
+ * and the fallback keeps a hand-built placement string from crashing the
167
+ * layout — it lands on the default instead.
168
+ */
169
+ function toSide(value: string): Side {
170
+ return value === "top" || value === "right" || value === "left"
171
+ ? value
172
+ : "bottom";
173
+ }
174
+
175
+ function toAlign(value: string): Align {
176
+ return value === "start" || value === "end" ? value : "center";
177
+ }
178
+
179
+ function isVertical(side: Side): boolean {
180
+ return side === "top" || side === "bottom";
181
+ }
182
+
183
+ /**
184
+ * Flip a placement across the vertical axis.
185
+ *
186
+ * Which half moves depends on the side. For `left` / `right` the *side* is the
187
+ * horizontal thing, so it swaps and the alignment (top/bottom) is untouched.
188
+ * For `top` / `bottom` the side is unaffected and it is the *alignment* that
189
+ * refers to a horizontal edge, so that swaps instead. Mirroring both would put
190
+ * a `bottom-start` menu back where it started.
191
+ */
192
+ function mirrorForRtl(side: Side, align: Align): [Side, Align] {
193
+ if (!isVertical(side)) return [opposite(side), align];
194
+ if (align === "center") return [side, align];
195
+ return [side, align === "start" ? "end" : "start"];
196
+ }
197
+
198
+ function opposite(side: Side): Side {
199
+ if (side === "top") return "bottom";
200
+ if (side === "bottom") return "top";
201
+ if (side === "left") return "right";
202
+ return "left";
203
+ }
204
+
205
+ /** Top-left corner of the surface for a given side and alignment. */
206
+ function coordsFor(
207
+ side: Side,
208
+ align: Align,
209
+ anchor: Rect,
210
+ floating: Rect,
211
+ offset: number,
212
+ ): { x: number; y: number } {
213
+ if (isVertical(side)) {
214
+ const y =
215
+ side === "bottom"
216
+ ? anchor.y + anchor.height + offset
217
+ : anchor.y - floating.height - offset;
218
+ return {
219
+ x: alignedStart(anchor.x, anchor.width, floating.width, align),
220
+ y,
221
+ };
222
+ }
223
+
224
+ const x =
225
+ side === "right"
226
+ ? anchor.x + anchor.width + offset
227
+ : anchor.x - floating.width - offset;
228
+ return {
229
+ x,
230
+ y: alignedStart(anchor.y, anchor.height, floating.height, align),
231
+ };
232
+ }
233
+
234
+ /** Where the surface starts along the cross axis, for one alignment. */
235
+ function alignedStart(
236
+ anchorStart: number,
237
+ anchorSize: number,
238
+ floatingSize: number,
239
+ align: Align,
240
+ ): number {
241
+ if (align === "start") return anchorStart;
242
+ if (align === "end") return anchorStart + anchorSize - floatingSize;
243
+ return anchorStart + (anchorSize - floatingSize) / 2;
244
+ }
245
+
246
+ /** Room between the anchor and the viewport edge on one side. */
247
+ function spaceOn(
248
+ side: Side,
249
+ anchor: Rect,
250
+ viewport: Viewport,
251
+ offset: number,
252
+ padding: number,
253
+ ): number {
254
+ const raw =
255
+ side === "top"
256
+ ? anchor.y
257
+ : side === "bottom"
258
+ ? viewport.height - (anchor.y + anchor.height)
259
+ : side === "left"
260
+ ? anchor.x
261
+ : viewport.width - (anchor.x + anchor.width);
262
+ return Math.max(0, raw - offset - padding);
263
+ }
264
+
265
+ /**
266
+ * Keep the requested side unless the opposite one is genuinely better.
267
+ *
268
+ * "Better" is not "has more room": flipping a menu that overflows by two
269
+ * pixels is more disruptive than letting it shift. So the opposite side has to
270
+ * both fit and beat the requested one. When neither fits, the roomier side
271
+ * wins and `availableHeight` lets the surface scroll inside it.
272
+ */
273
+ function bestSide(
274
+ requested: Side,
275
+ anchor: Rect,
276
+ floating: Rect,
277
+ viewport: Viewport,
278
+ offset: number,
279
+ padding: number,
280
+ ): Side {
281
+ const needed = isVertical(requested) ? floating.height : floating.width;
282
+ const here = spaceOn(requested, anchor, viewport, offset, padding);
283
+ if (here >= needed) return requested;
284
+
285
+ const other = opposite(requested);
286
+ const there = spaceOn(other, anchor, viewport, offset, padding);
287
+ if (there >= needed) return other;
288
+
289
+ return there > here ? other : requested;
290
+ }
291
+
292
+ /**
293
+ * Centre the arrow on the anchor, then pull it back off the corners.
294
+ *
295
+ * The arrow tracks the *anchor*, not the surface: once shift has slid the
296
+ * surface sideways, an arrow centred on the surface points at empty space.
297
+ * The clamp keeps it clear of the rounded corners, where it would otherwise
298
+ * poke out of the border radius.
299
+ */
300
+ function arrowOffset(
301
+ side: Side,
302
+ anchor: Rect,
303
+ floating: Rect,
304
+ coords: { x: number; y: number },
305
+ arrowSize: number,
306
+ padding: number,
307
+ ): { x?: number; y?: number } {
308
+ if (isVertical(side)) {
309
+ const centre = anchor.x + anchor.width / 2 - coords.x - arrowSize / 2;
310
+ const limit = floating.width - arrowSize - padding;
311
+ return { x: clamp(centre, padding, Math.max(padding, limit)) };
312
+ }
313
+ const centre = anchor.y + anchor.height / 2 - coords.y - arrowSize / 2;
314
+ const limit = floating.height - arrowSize - padding;
315
+ return { y: clamp(centre, padding, Math.max(padding, limit)) };
316
+ }
317
+
318
+ function clamp(value: number, min: number, max: number): number {
319
+ // `max` can fall below `min` when the surface is wider than the viewport.
320
+ // Pinning to `min` keeps the leading edge visible, which is the readable
321
+ // half; honouring `max` instead would push the start off screen.
322
+ if (max < min) return min;
323
+ return Math.min(Math.max(value, min), max);
324
+ }
325
+
326
+ // ─── DOM binding ─────────────────────────────────────────────────────
327
+
328
+ export interface AutoPositionOptions extends PositionOptions {
329
+ /** The arrow element to position, if the surface has one. */
330
+ arrow?: () => HTMLElement | null;
331
+ /** Force the surface to the anchor's width. Select and Combobox use it. */
332
+ matchWidth?: boolean;
333
+ /** Called after each reposition — for `data-side` attributes and the like. */
334
+ onPositioned?: (position: Position) => void;
335
+ }
336
+
337
+ export interface AutoPosition {
338
+ /** Recompute and rewrite the styles now. */
339
+ update(): void;
340
+ /** Stop watching. Styles already written are left in place. */
341
+ stop(): void;
342
+ }
343
+
344
+ /**
345
+ * Position `floating` against `anchor` and keep it there.
346
+ *
347
+ * Updates run on scroll (captured, so ancestor scroll containers are covered
348
+ * without walking the tree to find them) and on resize, plus whenever either
349
+ * element changes size where `ResizeObserver` exists. A menu whose content
350
+ * loads in and grows would otherwise stay positioned for its old height.
351
+ */
352
+ export function autoPosition(
353
+ anchor: HTMLElement,
354
+ floating: HTMLElement,
355
+ options: AutoPositionOptions = {},
356
+ ): AutoPosition {
357
+ function update(): void {
358
+ const anchorRect = anchor.getBoundingClientRect();
359
+ // Read per update, not once: a `dir` attribute can be toggled at runtime
360
+ // by a language switcher, and a surface open across that switch has to
361
+ // move with it.
362
+ const rtl = options.rtl ?? isRightToLeft(anchor);
363
+ if (options.matchWidth === true) {
364
+ floating.style.width = `${anchorRect.width}px`;
365
+ }
366
+
367
+ const floatingRect = floating.getBoundingClientRect();
368
+ const position = resolvePosition(
369
+ anchorRect,
370
+ floatingRect,
371
+ { width: window.innerWidth, height: window.innerHeight },
372
+ { ...options, rtl },
373
+ );
374
+
375
+ floating.style.position = "fixed";
376
+ floating.style.left = `${Math.round(position.x)}px`;
377
+ floating.style.top = `${Math.round(position.y)}px`;
378
+
379
+ // Exposed as custom properties so the *stylesheet* decides what to do with
380
+ // them. shadcn's menus read the available height through
381
+ // `max-h-(--nebula-available-height)`, which keeps the scroll decision in
382
+ // CSS rather than hardcoding a pixel max-height from script.
383
+ floating.style.setProperty(
384
+ "--nebula-available-height",
385
+ `${Math.round(position.availableHeight)}px`,
386
+ );
387
+ floating.style.setProperty(
388
+ "--nebula-anchor-width",
389
+ `${Math.round(anchorRect.width)}px`,
390
+ );
391
+ floating.setAttribute("data-side", position.side);
392
+ floating.setAttribute("data-align", position.align);
393
+
394
+ const arrowEl = options.arrow?.();
395
+ if (arrowEl && position.arrow) {
396
+ const { x, y } = position.arrow;
397
+ arrowEl.style.position = "absolute";
398
+ arrowEl.style.left = x === undefined ? "" : `${Math.round(x)}px`;
399
+ arrowEl.style.top = y === undefined ? "" : `${Math.round(y)}px`;
400
+ }
401
+
402
+ options.onPositioned?.(position);
403
+ }
404
+
405
+ update();
406
+
407
+ const onScrollOrResize = (): void => update();
408
+ window.addEventListener("scroll", onScrollOrResize, true);
409
+ window.addEventListener("resize", onScrollOrResize);
410
+
411
+ const observer = observeSizes([anchor, floating], update);
412
+
413
+ let stopped = false;
414
+ return {
415
+ update,
416
+ stop(): void {
417
+ if (stopped) return;
418
+ stopped = true;
419
+ window.removeEventListener("scroll", onScrollOrResize, true);
420
+ window.removeEventListener("resize", onScrollOrResize);
421
+ observer?.disconnect();
422
+ },
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Is this element laid out right to left?
428
+ *
429
+ * The computed style rather than the `dir` attribute, so an element inheriting
430
+ * the direction from `<html dir="rtl">` is answered correctly without every
431
+ * anchor having to carry the attribute itself.
432
+ */
433
+ function isRightToLeft(element: HTMLElement): boolean {
434
+ if (typeof getComputedStyle !== "function") return false;
435
+ return getComputedStyle(element).direction === "rtl";
436
+ }
437
+
438
+ /**
439
+ * Watch elements for size changes, where the platform supports it.
440
+ *
441
+ * happy-dom (nebula's unit-test environment) has no `ResizeObserver`, and the
442
+ * scroll and resize listeners already cover the common cases, so its absence
443
+ * degrades rather than throws.
444
+ */
445
+ function observeSizes(
446
+ elements: readonly HTMLElement[],
447
+ onResize: () => void,
448
+ ): ResizeObserver | null {
449
+ if (typeof ResizeObserver !== "function") return null;
450
+ const observer = new ResizeObserver(onResize);
451
+ for (const element of elements) observer.observe(element);
452
+ return observer;
453
+ }