@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.
@@ -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
- for (const file of adapter.files(config)) {
101
- await codemods.writeFile(file.path, file.contents);
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
- * Exported so a caller passing a plain `<input>` rather than nebula's `Input`
34
- * can apply it too without this the input draws a second border inside the
35
- * group's, which is the one way this component visibly goes wrong.
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",
@@ -78,6 +78,7 @@ export {
78
78
  export {
79
79
  InputGroup,
80
80
  InputGroupAddon,
81
+ InputGroupInput,
81
82
  type InputGroupProps,
82
83
  inputGroupControlClasses,
83
84
  } from "./InputGroup.js";
@@ -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
  }
@@ -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
- * happy-dom (nebula's unit-test environment) has no `ResizeObserver`, and the
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
- * 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
 
@@ -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 === null` catches `display: none` on the element or any
37
- * ancestor in one property read, which is much cheaper than walking the tree.
38
- * It reports null for `position: fixed` elements too, so those fall through to
39
- * the explicit style checks that branch is why dialogs and popovers, which
40
- * are routinely fixed, are not wrongly treated as hidden.
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
- if (el.offsetParent !== null) return true;
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 style = getComputedStyle(el);
49
- if (style.position !== "fixed") return false;
50
- return style.display !== "none" && style.visibility !== "hidden";
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
  /**