@c9up/nebula 0.1.2 → 0.1.4
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/README.md +4 -1
- package/dist/atoms/Slider.d.ts +1 -3
- package/dist/atoms/Slider.js +12 -5
- package/dist/cli/add.js +12 -0
- package/dist/cli/diff.d.ts +47 -0
- package/dist/cli/diff.js +78 -0
- package/dist/cli/manifest.d.ts +37 -0
- package/dist/cli/manifest.js +71 -0
- package/dist/configure.js +62 -3
- package/dist/console/DiffCommand.d.ts +21 -0
- package/dist/console/DiffCommand.js +78 -0
- package/dist/molecules/InputGroup.d.ts +18 -3
- package/dist/molecules/InputGroup.js +22 -4
- package/dist/molecules/index.d.ts +1 -1
- package/dist/molecules/index.js +1 -1
- package/dist/organisms/Form.js +9 -1
- package/dist/primitives/floating.js +1 -1
- package/dist/primitives/focusTrap.js +35 -3
- package/dist/primitives/focusable.d.ts +12 -5
- package/dist/primitives/focusable.js +68 -9
- package/nebula.css +1 -1
- package/package.json +6 -2
- package/registry.json +3 -1
- package/src/atoms/Slider.ts +13 -8
- package/src/cli/add.ts +12 -0
- package/src/cli/diff.ts +114 -0
- package/src/cli/manifest.ts +80 -0
- package/src/configure.ts +73 -3
- package/src/console/DiffCommand.ts +103 -0
- package/src/molecules/InputGroup.ts +24 -4
- package/src/molecules/index.ts +1 -0
- package/src/organisms/Form.ts +9 -1
- package/src/primitives/floating.ts +1 -1
- package/src/primitives/focusTrap.ts +35 -3
- package/src/primitives/focusable.ts +75 -9
package/src/cli/diff.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nebula diff` — which copied components no longer match the package.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart to `add`. Copying is one-way by design, so a fix released
|
|
5
|
+
* upstream never reaches a project on its own and nothing anywhere says so:
|
|
6
|
+
* not the lockfile, not `pnpm update`, not the file itself. This is the report
|
|
7
|
+
* that makes that visible.
|
|
8
|
+
*
|
|
9
|
+
* It answers with the *reason* a file differs, not just that it does. Every
|
|
10
|
+
* copied component is expected to diverge from upstream eventually — that is
|
|
11
|
+
* what owning the source means — so "differs" alone would flag the whole tree
|
|
12
|
+
* and mean nothing. The hash recorded at copy time is what separates a change
|
|
13
|
+
* you made from one you have not seen yet.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { defaultPaths, type Language, type NebulaPaths } from "../config.js";
|
|
19
|
+
import { detectLanguage } from "./add.js";
|
|
20
|
+
import { hashOf, readManifest } from "./manifest.js";
|
|
21
|
+
import { loadRegistry, packageRoot } from "./registry.js";
|
|
22
|
+
|
|
23
|
+
export type DiffState =
|
|
24
|
+
/** Copy, record and package all agree. */
|
|
25
|
+
| "same"
|
|
26
|
+
/** The package moved and the copy is untouched — a re-copy loses nothing. */
|
|
27
|
+
| "outdated"
|
|
28
|
+
/** The copy was edited; the package has not moved. */
|
|
29
|
+
| "edited"
|
|
30
|
+
/** Both moved. The only state that needs a decision. */
|
|
31
|
+
| "conflict"
|
|
32
|
+
/** Copied before anything was recorded, and it no longer matches. */
|
|
33
|
+
| "unknown";
|
|
34
|
+
|
|
35
|
+
export interface DiffEntry {
|
|
36
|
+
/** Path as the registry names it, relative to the components root. */
|
|
37
|
+
file: string;
|
|
38
|
+
state: DiffState;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DiffOptions {
|
|
42
|
+
cwd: string;
|
|
43
|
+
paths?: Partial<NebulaPaths>;
|
|
44
|
+
language?: Language;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface DiffResult {
|
|
48
|
+
entries: DiffEntry[];
|
|
49
|
+
language: Language;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compare every copied component against the installed package.
|
|
54
|
+
*
|
|
55
|
+
* Only files that are actually in the project are considered — the registry
|
|
56
|
+
* lists everything nebula ships, and a component nobody copied is not news.
|
|
57
|
+
*/
|
|
58
|
+
export function diff(options: DiffOptions): DiffResult {
|
|
59
|
+
const paths = { ...defaultPaths, ...options.paths };
|
|
60
|
+
const target = join(options.cwd, paths.components);
|
|
61
|
+
const language = options.language ?? detectLanguage(target) ?? "js";
|
|
62
|
+
|
|
63
|
+
const root = packageRoot(join(options.cwd, "package.json"));
|
|
64
|
+
const sourceRoot = join(root, language === "ts" ? "src" : "dist");
|
|
65
|
+
const registry = loadRegistry(join(root, "registry.json"));
|
|
66
|
+
const manifest = readManifest(target);
|
|
67
|
+
|
|
68
|
+
const entries: DiffEntry[] = [];
|
|
69
|
+
const seen = new Set<string>();
|
|
70
|
+
|
|
71
|
+
for (const item of registry.items) {
|
|
72
|
+
for (const file of item.files) {
|
|
73
|
+
// The registry names `.ts` paths; the compiled tree mirrors it exactly.
|
|
74
|
+
const relative = language === "ts" ? file : file.replace(/\.ts$/, ".js");
|
|
75
|
+
if (seen.has(relative)) continue;
|
|
76
|
+
seen.add(relative);
|
|
77
|
+
|
|
78
|
+
const copied = join(target, relative);
|
|
79
|
+
if (!existsSync(copied)) continue;
|
|
80
|
+
|
|
81
|
+
const state = compare({
|
|
82
|
+
copy: hashOf(copied),
|
|
83
|
+
source: hashOf(join(sourceRoot, relative)),
|
|
84
|
+
recorded: manifest.files[relative],
|
|
85
|
+
});
|
|
86
|
+
if (state !== "same") entries.push({ file: relative, state });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
entries.sort((a, b) => a.file.localeCompare(b.file));
|
|
91
|
+
return { entries, language };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function compare(hashes: {
|
|
95
|
+
copy: string | undefined;
|
|
96
|
+
source: string | undefined;
|
|
97
|
+
recorded: string | undefined;
|
|
98
|
+
}): DiffState {
|
|
99
|
+
const { copy, source, recorded } = hashes;
|
|
100
|
+
// Nothing to compare against: the package no longer ships this file, so
|
|
101
|
+
// whatever is in the project is now the only copy of it.
|
|
102
|
+
if (source === undefined) return "same";
|
|
103
|
+
if (copy === source) return "same";
|
|
104
|
+
|
|
105
|
+
// Copied before the manifest existed. The two differ, and there is no way
|
|
106
|
+
// to tell which side moved.
|
|
107
|
+
if (recorded === undefined) return "unknown";
|
|
108
|
+
|
|
109
|
+
const copyTouched = copy !== recorded;
|
|
110
|
+
const sourceMoved = source !== recorded;
|
|
111
|
+
if (copyTouched && sourceMoved) return "conflict";
|
|
112
|
+
if (sourceMoved) return "outdated";
|
|
113
|
+
return "edited";
|
|
114
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a copied component looked like when it was copied.
|
|
3
|
+
*
|
|
4
|
+
* The registry model hands you the source and steps back: from then on the file
|
|
5
|
+
* is yours, and nothing upgrades it. That is the point, and it is also the one
|
|
6
|
+
* thing it cannot tell you — when a component is fixed upstream, the copy in
|
|
7
|
+
* your project stays as it was, and no install, lockfile or `pnpm update`
|
|
8
|
+
* mentions it. shadcn has the same gap.
|
|
9
|
+
*
|
|
10
|
+
* Recording the hash of what was copied is what closes it. With it, three
|
|
11
|
+
* different situations stop looking alike:
|
|
12
|
+
*
|
|
13
|
+
* - the file still matches what was copied → upstream moving is a safe re-copy
|
|
14
|
+
* - the file was edited and upstream has not moved → nothing to do
|
|
15
|
+
* - both moved → a decision, and the only case that needs a person
|
|
16
|
+
*
|
|
17
|
+
* Without it, every edited component reads as "differs from upstream", which is
|
|
18
|
+
* true of all of them by design and therefore says nothing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Lives beside the components it describes, so moving the tree keeps it. */
|
|
26
|
+
export const MANIFEST_FILE = ".nebula.json";
|
|
27
|
+
|
|
28
|
+
export interface Manifest {
|
|
29
|
+
/** Component path (as the registry names it) → hash of the copied bytes. */
|
|
30
|
+
files: Record<string, string>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Content hash of a file's bytes, or `undefined` when it is not there. */
|
|
34
|
+
export function hashOf(path: string): string | undefined {
|
|
35
|
+
if (!existsSync(path)) return undefined;
|
|
36
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read the manifest for a component tree.
|
|
41
|
+
*
|
|
42
|
+
* A missing or unreadable one is an empty manifest, never an error: a project
|
|
43
|
+
* that copied components before this existed still has to work, and it simply
|
|
44
|
+
* has nothing recorded yet.
|
|
45
|
+
*/
|
|
46
|
+
export function readManifest(componentsRoot: string): Manifest {
|
|
47
|
+
const path = join(componentsRoot, MANIFEST_FILE);
|
|
48
|
+
if (!existsSync(path)) return { files: {} };
|
|
49
|
+
try {
|
|
50
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
51
|
+
if (typeof parsed !== "object" || parsed === null) return { files: {} };
|
|
52
|
+
const files = Reflect.get(parsed, "files");
|
|
53
|
+
if (typeof files !== "object" || files === null) return { files: {} };
|
|
54
|
+
const entries: Record<string, string> = {};
|
|
55
|
+
for (const [key, value] of Object.entries(files)) {
|
|
56
|
+
if (typeof value === "string") entries[key] = value;
|
|
57
|
+
}
|
|
58
|
+
return { files: entries };
|
|
59
|
+
} catch {
|
|
60
|
+
return { files: {} };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Merge new entries in and write the manifest back, sorted for a clean diff. */
|
|
65
|
+
export function writeManifest(
|
|
66
|
+
componentsRoot: string,
|
|
67
|
+
added: Record<string, string>,
|
|
68
|
+
): void {
|
|
69
|
+
const existing = readManifest(componentsRoot);
|
|
70
|
+
const merged = { ...existing.files, ...added };
|
|
71
|
+
const sorted: Record<string, string> = {};
|
|
72
|
+
for (const key of Object.keys(merged).sort()) {
|
|
73
|
+
const value = merged[key];
|
|
74
|
+
if (value !== undefined) sorted[key] = value;
|
|
75
|
+
}
|
|
76
|
+
writeFileSync(
|
|
77
|
+
join(componentsRoot, MANIFEST_FILE),
|
|
78
|
+
`${JSON.stringify({ files: sorted }, null, 2)}\n`,
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/configure.ts
CHANGED
|
@@ -16,7 +16,10 @@
|
|
|
16
16
|
* its adapters run at build time. An empty provider would be ceremony.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { resolve } from "node:path";
|
|
19
21
|
import { adapterFor, isAdapterName } from "./adapters/index.js";
|
|
22
|
+
import type { GeneratedFile } from "./adapters/types.js";
|
|
20
23
|
import { type AdapterName, resolveConfig } from "./config.js";
|
|
21
24
|
|
|
22
25
|
/**
|
|
@@ -96,9 +99,23 @@ export async function configure(
|
|
|
96
99
|
// line of Rust and without waiting on a release of the binary.
|
|
97
100
|
await codemods.registerCommand("@c9up/nebula/commands/add");
|
|
98
101
|
await codemods.registerCommand("@c9up/nebula/commands/list");
|
|
102
|
+
await codemods.registerCommand("@c9up/nebula/commands/diff");
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
104
|
+
const generated = adapter.files(config);
|
|
105
|
+
for (const file of generated) {
|
|
106
|
+
// `skipIfExists` is the adapter saying the app owns this file. Pass it
|
|
107
|
+
// through rather than relying on the codemod's default, so the contract
|
|
108
|
+
// is the thing that decides.
|
|
109
|
+
await codemods.writeFile(file.path, file.contents, {
|
|
110
|
+
force: !file.skipIfExists,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const gaps: Array<{ path: string; missing: string[] }> = [];
|
|
115
|
+
for (const file of generated) {
|
|
116
|
+
if (!file.skipIfExists) continue;
|
|
117
|
+
const missing = await missingFrom(file);
|
|
118
|
+
if (missing.length > 0) gaps.push({ path: file.path, missing });
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
// On stderr, and never installed on the user's behalf. nebula declares no
|
|
@@ -128,7 +145,60 @@ export async function configure(
|
|
|
128
145
|
lines.push(
|
|
129
146
|
" Then add components with `ream nebula:add button card` — they are copied",
|
|
130
147
|
" into your project and are yours to edit.",
|
|
131
|
-
"",
|
|
132
148
|
);
|
|
149
|
+
|
|
150
|
+
for (const gap of gaps) {
|
|
151
|
+
lines.push(
|
|
152
|
+
"",
|
|
153
|
+
` ${gap.path} already existed, so it was left alone — it is yours.`,
|
|
154
|
+
" The components resolve against tokens it does not define:",
|
|
155
|
+
...gap.missing.map((token) => ` ${token}`),
|
|
156
|
+
"",
|
|
157
|
+
' Import nebula\'s theme (`@import "@c9up/nebula/theme.css"` plus the',
|
|
158
|
+
" `@theme inline` block), or declare them yourself. Without them the",
|
|
159
|
+
" utilities still compile and every colour resolves to nothing.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
lines.push("");
|
|
133
164
|
process.stderr.write(lines.join("\n"));
|
|
134
165
|
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Which of the tokens the components resolve against are absent from the
|
|
169
|
+
* stylesheet that was left in place.
|
|
170
|
+
*
|
|
171
|
+
* `skipIfExists` means an app's own stylesheet is never overwritten, which is
|
|
172
|
+
* right — it is theirs once it exists. The cost is that a project that already
|
|
173
|
+
* had one gets the components copied in and nothing for them to resolve
|
|
174
|
+
* against. Nothing throws, every class name is present in the markup, and the
|
|
175
|
+
* page comes out grey.
|
|
176
|
+
*
|
|
177
|
+
* Tokens rather than directives, because there is more than one correct way to
|
|
178
|
+
* supply them: importing nebula's theme, or declaring `--color-*` directly in
|
|
179
|
+
* an `@theme` block. Only the second half of that is checkable by reading, so
|
|
180
|
+
* the import short-circuits the question.
|
|
181
|
+
*/
|
|
182
|
+
async function missingFrom(file: GeneratedFile): Promise<string[]> {
|
|
183
|
+
let existing: string;
|
|
184
|
+
try {
|
|
185
|
+
existing = await readFile(resolve(process.cwd(), file.path), "utf8");
|
|
186
|
+
} catch {
|
|
187
|
+
// No file to read means nothing was skipped: it was written as generated.
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
if (existing.includes("@c9up/nebula/theme.css")) return [];
|
|
191
|
+
return colorTokensOf(file.contents).filter(
|
|
192
|
+
(token) => !new RegExp(`${token}\\s*:`).test(existing),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The `--color-*` names a generated stylesheet makes available. */
|
|
197
|
+
function colorTokensOf(css: string): string[] {
|
|
198
|
+
const found = new Set<string>();
|
|
199
|
+
for (const match of css.matchAll(/(--color-[a-z-]+)\s*:/g)) {
|
|
200
|
+
const name = match[1];
|
|
201
|
+
if (name !== undefined) found.add(name);
|
|
202
|
+
}
|
|
203
|
+
return [...found];
|
|
204
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ream nebula:diff` — which copied components no longer match the package.
|
|
3
|
+
*
|
|
4
|
+
* `nebula:add` hands you the source and steps back, so a fix released upstream
|
|
5
|
+
* never reaches a project on its own. Nothing else reports that: the files are
|
|
6
|
+
* local, no lockfile mentions them and `pnpm update` does not touch them. This
|
|
7
|
+
* is the one place it becomes visible.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { type DiffState, diff } from "../cli/diff.js";
|
|
11
|
+
import { flag, type NebulaCommandClass } from "./contract.js";
|
|
12
|
+
|
|
13
|
+
/** What each state means, and what to do about it. */
|
|
14
|
+
const EXPLAIN: Record<DiffState, string> = {
|
|
15
|
+
same: "",
|
|
16
|
+
outdated: "the package moved, your copy is untouched — re-copy loses nothing",
|
|
17
|
+
edited: "you changed it; the package has not moved",
|
|
18
|
+
conflict: "you changed it AND the package moved — read both before copying",
|
|
19
|
+
unknown: "copied before nebula recorded hashes; cannot tell which side moved",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default class NebulaDiffCommand {
|
|
23
|
+
static commandName = "nebula:diff";
|
|
24
|
+
static description =
|
|
25
|
+
"Report copied components that no longer match the installed package";
|
|
26
|
+
|
|
27
|
+
// Files only; nothing here needs the container or a booted app.
|
|
28
|
+
static options = { startApp: false };
|
|
29
|
+
|
|
30
|
+
static args = [];
|
|
31
|
+
|
|
32
|
+
static flags = [
|
|
33
|
+
flag("ts", "boolean", {
|
|
34
|
+
description: "Compare against the TypeScript sources",
|
|
35
|
+
}),
|
|
36
|
+
flag("js", "boolean", {
|
|
37
|
+
description: "Compare against the compiled JavaScript",
|
|
38
|
+
}),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
static help = [
|
|
42
|
+
" ream nebula:diff",
|
|
43
|
+
"",
|
|
44
|
+
"Components are copied, so nothing upgrades them. This says which ones the",
|
|
45
|
+
"package has since changed, and separates that from the edits you made —",
|
|
46
|
+
"every component is expected to diverge eventually, so 'differs' alone",
|
|
47
|
+
"would flag the whole tree and say nothing.",
|
|
48
|
+
"",
|
|
49
|
+
"Update one with `ream nebula:add <name> --force`, after reading the change",
|
|
50
|
+
"if you had edited it.",
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
ts = false;
|
|
54
|
+
js = false;
|
|
55
|
+
|
|
56
|
+
async run(): Promise<void> {
|
|
57
|
+
const result = diff({
|
|
58
|
+
cwd: process.cwd(),
|
|
59
|
+
language: this.ts ? "ts" : this.js ? "js" : undefined,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (result.entries.length === 0) {
|
|
63
|
+
process.stdout.write(
|
|
64
|
+
"\n Every copied component matches the installed package.\n\n",
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const width = Math.max(...result.entries.map((e) => e.file.length));
|
|
70
|
+
const out: string[] = [""];
|
|
71
|
+
for (const entry of result.entries) {
|
|
72
|
+
out.push(
|
|
73
|
+
` ${entry.state.padEnd(9)} ${entry.file.padEnd(width)} ${EXPLAIN[entry.state]}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const outdated = result.entries.filter(
|
|
78
|
+
(entry) => entry.state === "outdated",
|
|
79
|
+
);
|
|
80
|
+
if (outdated.length > 0) {
|
|
81
|
+
out.push(
|
|
82
|
+
"",
|
|
83
|
+
` ${outdated.length} can be updated with no loss:`,
|
|
84
|
+
` ream nebula:add --force ${outdated.map(componentOf).join(" ")}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
out.push("");
|
|
88
|
+
process.stdout.write(out.join("\n"));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `molecules/InputGroup.js` → `input-group`, as the registry names it. */
|
|
93
|
+
function componentOf(entry: { file: string }): string {
|
|
94
|
+
const base = entry.file.split("/").pop() ?? entry.file;
|
|
95
|
+
return base
|
|
96
|
+
.replace(/\.(ts|js)$/, "")
|
|
97
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
98
|
+
.toLowerCase();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fails the build if the class drifts from what the kernel dispatches against.
|
|
102
|
+
const _contract: NebulaCommandClass = NebulaDiffCommand;
|
|
103
|
+
void _contract;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { component, html } from "@c9up/aurora";
|
|
14
|
+
import { Input, type InputProps } from "../atoms/Input.js";
|
|
14
15
|
import { type Slot, slot } from "../lib/children.js";
|
|
15
16
|
import { cn } from "../lib/cn.js";
|
|
16
17
|
import { type Reactive, read } from "../lib/props.js";
|
|
@@ -30,9 +31,14 @@ export interface InputGroupProps {
|
|
|
30
31
|
/**
|
|
31
32
|
* Strip the inner input of its own chrome.
|
|
32
33
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
34
|
+
* Put these ON the input. The group cannot do it from the outside: a descendant
|
|
35
|
+
* variant like `[&_input]:border-0` has to exist as a literal for Tailwind to
|
|
36
|
+
* generate a rule for it, and building one by joining this string at runtime
|
|
37
|
+
* produces class names no stylesheet ever defines — the input keeps its border
|
|
38
|
+
* and draws a second one inside the group's, with nothing raised to say so.
|
|
39
|
+
*
|
|
40
|
+
* {@link InputGroupInput} applies them for you. This export is for a caller
|
|
41
|
+
* passing a plain `<input>` instead.
|
|
36
42
|
*/
|
|
37
43
|
export const inputGroupControlClasses =
|
|
38
44
|
"flex-1 border-0 bg-transparent px-0 shadow-none outline-none focus-visible:border-0 focus-visible:ring-0 disabled:opacity-100";
|
|
@@ -48,7 +54,6 @@ export const InputGroup = component<InputGroupProps>((props) => {
|
|
|
48
54
|
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
|
49
55
|
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
|
|
50
56
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
51
|
-
`[&_input]:${inputGroupControlClasses.split(" ").join(" [&_input]:")}`,
|
|
52
57
|
read(props.class),
|
|
53
58
|
)}"
|
|
54
59
|
>
|
|
@@ -56,6 +61,21 @@ export const InputGroup = component<InputGroupProps>((props) => {
|
|
|
56
61
|
</div>`;
|
|
57
62
|
});
|
|
58
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The input that belongs inside an {@link InputGroup}.
|
|
66
|
+
*
|
|
67
|
+
* Same component as {@link Input}, with the group's stripping applied first so
|
|
68
|
+
* a caller's own `class` still wins. This is where the classes have to live:
|
|
69
|
+
* the group carries the border and the ring, and the input has to bring none
|
|
70
|
+
* of its own.
|
|
71
|
+
*/
|
|
72
|
+
export const InputGroupInput = component<InputProps>((props) =>
|
|
73
|
+
Input({
|
|
74
|
+
...props,
|
|
75
|
+
class: () => cn(inputGroupControlClasses, read(props.class)),
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
|
|
59
79
|
export const InputGroupAddon = styledDiv(
|
|
60
80
|
"input-group-addon",
|
|
61
81
|
"text-muted-foreground flex shrink-0 items-center gap-2 [&_svg:not([class*='size-'])]:size-4",
|
package/src/molecules/index.ts
CHANGED
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
|
}
|
|
@@ -438,7 +438,7 @@ function isRightToLeft(element: HTMLElement): boolean {
|
|
|
438
438
|
/**
|
|
439
439
|
* Watch elements for size changes, where the platform supports it.
|
|
440
440
|
*
|
|
441
|
-
*
|
|
441
|
+
* jsdom (nebula's unit-test environment) has no `ResizeObserver`, and the
|
|
442
442
|
* scroll and resize listeners already cover the common cases, so its absence
|
|
443
443
|
* degrades rather than throws.
|
|
444
444
|
*/
|
|
@@ -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
|
|
|
@@ -30,24 +30,90 @@ const FOCUSABLE_SELECTOR = [
|
|
|
30
30
|
"[tabindex]",
|
|
31
31
|
].join(",");
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Does a collapsed `<details>` hide this element?
|
|
35
|
+
*
|
|
36
|
+
* Nothing else in this file can answer it. A browser refuses to render the
|
|
37
|
+
* contents of a closed `<details>`, yet it still hands out a full box for
|
|
38
|
+
* them: Chromium reports `offsetParent: <body>`, a 62x21 rect and
|
|
39
|
+
* `display: inline-block` for a button inside one, while `checkVisibility()`
|
|
40
|
+
* answers false and `focus()` silently does nothing. Layout lies here and the
|
|
41
|
+
* computed styles lie with it, so only the markup can be asked.
|
|
42
|
+
*
|
|
43
|
+
* The `<summary>` is the exception: it is the part that stays rendered, and it
|
|
44
|
+
* is focusable in both states. The walk continues past an open `<details>`
|
|
45
|
+
* because a nested one can still sit inside a collapsed parent.
|
|
46
|
+
*/
|
|
47
|
+
function isInsideClosedDetails(el: HTMLElement): boolean {
|
|
48
|
+
for (
|
|
49
|
+
let details = el.closest("details");
|
|
50
|
+
details !== null;
|
|
51
|
+
details = details.parentElement?.closest("details") ?? null
|
|
52
|
+
) {
|
|
53
|
+
if (details.open) continue;
|
|
54
|
+
const summary = details.querySelector(":scope > summary");
|
|
55
|
+
if (summary === null || !summary.contains(el)) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The next node up, leaving a shadow tree by its host.
|
|
62
|
+
*
|
|
63
|
+
* `parentElement` is null for the top node of a shadow tree — what sits above
|
|
64
|
+
* it is a DocumentFragment, not an Element — so an ancestor walk stops at the
|
|
65
|
+
* boundary and never sees a host that is `display: none`. The node inside then
|
|
66
|
+
* keeps its own `display: inline-block` and reads as rendered while the browser
|
|
67
|
+
* gives it a zero-sized box.
|
|
68
|
+
*/
|
|
69
|
+
function ancestorOf(node: HTMLElement): HTMLElement | null {
|
|
70
|
+
if (node.parentElement !== null) return node.parentElement;
|
|
71
|
+
const root = node.getRootNode();
|
|
72
|
+
return root instanceof ShadowRoot && root.host instanceof HTMLElement
|
|
73
|
+
? root.host
|
|
74
|
+
: null;
|
|
75
|
+
}
|
|
76
|
+
|
|
33
77
|
/**
|
|
34
78
|
* Is the element rendered and interactive right now?
|
|
35
79
|
*
|
|
36
|
-
* `offsetParent
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
80
|
+
* `offsetParent` is a layout answer, and only a real layout engine has one to
|
|
81
|
+
* give. An element there is positive proof that this node is rendered, so it
|
|
82
|
+
* stays the cheap fast path. Its *absence* proves nothing: a browser reports
|
|
83
|
+
* null for `position: fixed` nodes and for `<body>`, and a headless DOM has no
|
|
84
|
+
* layout at all — jsdom answers null for every element alike, happy-dom does
|
|
85
|
+
* not implement the property. Reading that absence as "hidden" is what makes
|
|
86
|
+
* the check fall through to the styles instead of answering from it.
|
|
87
|
+
*
|
|
88
|
+
* The fallback walks the ancestors because `display` does not inherit: a node
|
|
89
|
+
* with `display: block` inside a `display: none` container is still not
|
|
90
|
+
* rendered. `visibility` does inherit, so the element's own value settles it,
|
|
91
|
+
* and the walk crosses shadow boundaries because the host can hide the tree.
|
|
41
92
|
*/
|
|
42
93
|
export function isVisible(el: HTMLElement): boolean {
|
|
43
94
|
if (el.hasAttribute("inert")) return false;
|
|
44
95
|
if (el.closest("[inert]") !== null) return false;
|
|
45
96
|
|
|
46
|
-
|
|
97
|
+
// These two settle before the fast path because they are the cases layout
|
|
98
|
+
// answers *wrongly* rather than not at all: `offsetParent` reports a
|
|
99
|
+
// confident ancestor for a node the browser will not render or focus.
|
|
100
|
+
if (!el.isConnected) return false;
|
|
101
|
+
if (isInsideClosedDetails(el)) return false;
|
|
102
|
+
|
|
103
|
+
if (el.offsetParent instanceof Element) return true;
|
|
47
104
|
|
|
48
|
-
const
|
|
49
|
-
if (
|
|
50
|
-
|
|
105
|
+
const own = getComputedStyle(el);
|
|
106
|
+
if (own.visibility === "hidden" || own.visibility === "collapse")
|
|
107
|
+
return false;
|
|
108
|
+
|
|
109
|
+
for (
|
|
110
|
+
let node: HTMLElement | null = el;
|
|
111
|
+
node !== null;
|
|
112
|
+
node = ancestorOf(node)
|
|
113
|
+
) {
|
|
114
|
+
if (getComputedStyle(node).display === "none") return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
51
117
|
}
|
|
52
118
|
|
|
53
119
|
/**
|