@c9up/nebula 0.1.2 → 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/organisms/Form.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
67
|
-
*
|
|
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.
|
|
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": "^
|
|
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/organisms/Form.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
|