@c9up/nebula 0.1.1 → 0.1.3

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.
package/dist/configure.js CHANGED
@@ -72,10 +72,7 @@ export async function configure(codemods, flags = {}) {
72
72
  // CSS dependency at all — the app owns its build tooling, and a UI library
73
73
  // quietly adding one to someone's dependency tree is how a tree gets away
74
74
  // from them.
75
- const lines = [
76
- "",
77
- "[@c9up/nebula] configured with the " + name + " adapter.",
78
- ];
75
+ const lines = ["", `[@c9up/nebula] configured with the ${name} adapter.`];
79
76
  if (adapter.packages.length > 0) {
80
77
  lines.push(" Install the engine yourself:", ` pnpm add -D ${adapter.packages.join(" ")}`);
81
78
  }
@@ -59,7 +59,15 @@ export function Form(props) {
59
59
  novalidate
60
60
  class="${() => cn("flex flex-col gap-6", read(props.class))}"
61
61
  @submit="${(event) => {
62
- void props.form.handleSubmit(event);
62
+ // A DOM listener returns nothing, so this promise is nobody's to
63
+ // await. `command.run` always resolves — failures route to
64
+ // `onFail` — but `validate()` runs first, and a validator that
65
+ // throws rejects here: the form would silently do nothing and leave
66
+ // a bare `Uncaught (in promise)` behind. Reported, so the thing
67
+ // that broke is the thing the console names.
68
+ void props.form.handleSubmit(event).catch((error) => {
69
+ console.error("[nebula] Form submit failed:", error);
70
+ });
63
71
  }}"
64
72
  >${slot(props.children)}</form>`;
65
73
  }
@@ -21,6 +21,20 @@
21
21
  * as a flicker and confuses assistive technology.
22
22
  */
23
23
  import { firstFocusable, focusableWithin, focusSilently } from "./focusable.js";
24
+ /**
25
+ * Every active trap, in the order they were created — innermost last.
26
+ *
27
+ * Only the last one acts. `contains` was the guard before, on the assumption
28
+ * that a nested dialog is a DESCENDANT of the one it opened over. Every modal
29
+ * surface here is portalled to `document.body`, so two open dialogs are
30
+ * SIBLINGS: each trap saw the other's focus as outside itself and pulled it
31
+ * back, and the focus bounced between them until it settled in the wrong one.
32
+ *
33
+ * A stack is also what makes release order not matter: an outer surface that
34
+ * closes first splices itself out, and whichever trap is innermost then is the
35
+ * one holding focus.
36
+ */
37
+ const active = [];
24
38
  /**
25
39
  * Trap focus inside `container` until the returned handle is released.
26
40
  *
@@ -30,6 +44,17 @@ import { firstFocusable, focusableWithin, focusSilently } from "./focusable.js";
30
44
  */
31
45
  export function focusTrap(container, options = {}) {
32
46
  const previouslyFocused = activeElement();
47
+ /**
48
+ * This trap's identity in the {@link active} stack, pushed BEFORE the
49
+ * initial focus: moving focus fires `focusin`, and a trap that was not yet
50
+ * on the stack had its own initial focus reclaimed by the one below it.
51
+ */
52
+ const token = {};
53
+ active.push(token);
54
+ /** Whether this is the trap the user is actually inside. */
55
+ function isTopmost() {
56
+ return active[active.length - 1] === token;
57
+ }
33
58
  if (!container.hasAttribute("tabindex")) {
34
59
  container.setAttribute("tabindex", "-1");
35
60
  }
@@ -40,6 +65,8 @@ export function focusTrap(container, options = {}) {
40
65
  function onKeyDown(event) {
41
66
  if (event.key !== "Tab")
42
67
  return;
68
+ if (!isTopmost())
69
+ return;
43
70
  const focusables = focusableWithin(container);
44
71
  if (focusables.length === 0) {
45
72
  // Nothing to cycle through: hold focus on the container so Tab cannot
@@ -63,11 +90,12 @@ export function focusTrap(container, options = {}) {
63
90
  /**
64
91
  * Pull focus back when it lands outside by any route other than Tab.
65
92
  *
66
- * Guarded on `contains`, and only ever moves focus *into* the trap, so it
67
- * cannot fight a nested trap: an inner dialog's container is a descendant
68
- * of the outer one, so the outer handler sees the focus as already inside.
93
+ * Only the topmost trap does this. See {@link active}: guarding on
94
+ * `contains` alone made two portalled siblings fight over the focus.
69
95
  */
70
96
  function onFocusIn(event) {
97
+ if (!isTopmost())
98
+ return;
71
99
  const target = event.target;
72
100
  if (!(target instanceof Node))
73
101
  return;
@@ -83,6 +111,10 @@ export function focusTrap(container, options = {}) {
83
111
  if (released)
84
112
  return;
85
113
  released = true;
114
+ // Spliced, not popped: surfaces do not always close innermost-first.
115
+ const index = active.indexOf(token);
116
+ if (index !== -1)
117
+ active.splice(index, 1);
86
118
  document.removeEventListener("keydown", onKeyDown, true);
87
119
  document.removeEventListener("focusin", onFocusIn, true);
88
120
  const target = options.returnFocus?.() ?? previouslyFocused;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@c9up/nebula",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Nebula — shadcn/ui ported to Aurora, organised as atomic design. Zero-dependency headless primitives, cva reimplemented, copy-the-source registry.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -64,7 +64,7 @@
64
64
  "@c9up/aurora": "^0.1.30",
65
65
  "@tailwindcss/cli": "^4.3.3",
66
66
  "@types/node": "^22.19.15",
67
- "happy-dom": "^15.11.7",
67
+ "happy-dom": "^20.12.0",
68
68
  "tailwindcss": "^4.3.3",
69
69
  "typescript": "^6.0.2",
70
70
  "vitest": "4.1.9"
package/src/configure.ts CHANGED
@@ -105,10 +105,7 @@ export async function configure(
105
105
  // CSS dependency at all — the app owns its build tooling, and a UI library
106
106
  // quietly adding one to someone's dependency tree is how a tree gets away
107
107
  // from them.
108
- const lines = [
109
- "",
110
- "[@c9up/nebula] configured with the " + name + " adapter.",
111
- ];
108
+ const lines = ["", `[@c9up/nebula] configured with the ${name} adapter.`];
112
109
 
113
110
  if (adapter.packages.length > 0) {
114
111
  lines.push(
@@ -89,7 +89,15 @@ export function Form<T>(props: FormProps<T>): ReturnType<typeof html> {
89
89
  novalidate
90
90
  class="${() => cn("flex flex-col gap-6", read(props.class))}"
91
91
  @submit="${(event: Event) => {
92
- void props.form.handleSubmit(event);
92
+ // A DOM listener returns nothing, so this promise is nobody's to
93
+ // await. `command.run` always resolves — failures route to
94
+ // `onFail` — but `validate()` runs first, and a validator that
95
+ // throws rejects here: the form would silently do nothing and leave
96
+ // a bare `Uncaught (in promise)` behind. Reported, so the thing
97
+ // that broke is the thing the console names.
98
+ void props.form.handleSubmit(event).catch((error: unknown) => {
99
+ console.error("[nebula] Form submit failed:", error);
100
+ });
93
101
  }}"
94
102
  >${slot(props.children)}</form>`;
95
103
  }
@@ -23,6 +23,21 @@
23
23
 
24
24
  import { firstFocusable, focusableWithin, focusSilently } from "./focusable.js";
25
25
 
26
+ /**
27
+ * Every active trap, in the order they were created — innermost last.
28
+ *
29
+ * Only the last one acts. `contains` was the guard before, on the assumption
30
+ * that a nested dialog is a DESCENDANT of the one it opened over. Every modal
31
+ * surface here is portalled to `document.body`, so two open dialogs are
32
+ * SIBLINGS: each trap saw the other's focus as outside itself and pulled it
33
+ * back, and the focus bounced between them until it settled in the wrong one.
34
+ *
35
+ * A stack is also what makes release order not matter: an outer surface that
36
+ * closes first splices itself out, and whichever trap is innermost then is the
37
+ * one holding focus.
38
+ */
39
+ const active: object[] = [];
40
+
26
41
  export interface FocusTrapOptions {
27
42
  /**
28
43
  * Where focus goes when the trap activates. Defaults to the first focusable
@@ -56,6 +71,19 @@ export function focusTrap(
56
71
  ): FocusTrap {
57
72
  const previouslyFocused = activeElement();
58
73
 
74
+ /**
75
+ * This trap's identity in the {@link active} stack, pushed BEFORE the
76
+ * initial focus: moving focus fires `focusin`, and a trap that was not yet
77
+ * on the stack had its own initial focus reclaimed by the one below it.
78
+ */
79
+ const token = {};
80
+ active.push(token);
81
+
82
+ /** Whether this is the trap the user is actually inside. */
83
+ function isTopmost(): boolean {
84
+ return active[active.length - 1] === token;
85
+ }
86
+
59
87
  if (!container.hasAttribute("tabindex")) {
60
88
  container.setAttribute("tabindex", "-1");
61
89
  }
@@ -68,6 +96,7 @@ export function focusTrap(
68
96
 
69
97
  function onKeyDown(event: KeyboardEvent): void {
70
98
  if (event.key !== "Tab") return;
99
+ if (!isTopmost()) return;
71
100
 
72
101
  const focusables = focusableWithin(container);
73
102
  if (focusables.length === 0) {
@@ -94,11 +123,11 @@ export function focusTrap(
94
123
  /**
95
124
  * Pull focus back when it lands outside by any route other than Tab.
96
125
  *
97
- * Guarded on `contains`, and only ever moves focus *into* the trap, so it
98
- * cannot fight a nested trap: an inner dialog's container is a descendant
99
- * of the outer one, so the outer handler sees the focus as already inside.
126
+ * Only the topmost trap does this. See {@link active}: guarding on
127
+ * `contains` alone made two portalled siblings fight over the focus.
100
128
  */
101
129
  function onFocusIn(event: FocusEvent): void {
130
+ if (!isTopmost()) return;
102
131
  const target = event.target;
103
132
  if (!(target instanceof Node)) return;
104
133
  if (container.contains(target)) return;
@@ -113,6 +142,9 @@ export function focusTrap(
113
142
  release(): void {
114
143
  if (released) return;
115
144
  released = true;
145
+ // Spliced, not popped: surfaces do not always close innermost-first.
146
+ const index = active.indexOf(token);
147
+ if (index !== -1) active.splice(index, 1);
116
148
  document.removeEventListener("keydown", onKeyDown, true);
117
149
  document.removeEventListener("focusin", onFocusIn, true);
118
150