@spicetify/kit 0.1.0 → 0.2.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.
package/dist/check.js CHANGED
@@ -13,6 +13,7 @@
13
13
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
14
  import path from "node:path";
15
15
  import { resolveModuleDir } from "./build.js";
16
+ import { checkExternalStdlibBoundary } from "./stdlib-boundary.js";
16
17
  const SEMVER = /^\d+\.\d+\.\d+(?:[-+][\w.-]+)?$/;
17
18
  // The shape of a client webpack hash: a separator-free alphanumeric token,
18
19
  // 8+ chars, mixed case (real module classes are lower-kebab, e.g.
@@ -89,15 +90,15 @@ export function checkSource(rel, text, options = {}) {
89
90
  });
90
91
  }
91
92
  // A second React instance breaks hooks/context identity (one-React
92
- // rule). expose/React.ts is stdlib's sanctioned single source and is
93
- // exempt.
93
+ // rule). The public stdlib barrel and the build-time bare-specifier shim
94
+ // both resolve to the client's captured instance.
94
95
  if (/from\s+['"]https?:\/\/[^'"]*react[^'"]*['"]/.test(line) &&
95
96
  !line.includes("jsx-runtime") &&
96
97
  !/expose\/React/.test(rel)) {
97
98
  out.push({
98
99
  severity: "warn",
99
100
  rule: "one-react",
100
- message: "import React from stdlib's expose or the bare 'react' shim, never a second copy",
101
+ message: "import React from stdlib's public barrel or the bare 'react' shim, never a second copy",
101
102
  file: at,
102
103
  });
103
104
  }
@@ -256,11 +257,14 @@ export function checkModule(dir) {
256
257
  ];
257
258
  }
258
259
  findings.push(...checkMetadata(meta));
260
+ findings.push(...checkExternalStdlibBoundary(dir, meta));
259
261
  findings.push(...checkEntryShim(dir, meta));
260
262
  findings.push(...checkStructure(dir, meta));
261
- const enforceClientBoundary = meta.kind === "extension";
262
263
  for (const { file, text } of readSources(dir)) {
263
- findings.push(...checkSource(path.relative(dir, file), text, { clientCapabilities: enforceClientBoundary }));
264
+ // The stdlib-boundary audit above owns ambient-client findings for full
265
+ // modules. Keep checkSource's standalone compatibility API but avoid a
266
+ // duplicate warning here.
267
+ findings.push(...checkSource(path.relative(dir, file), text, { clientCapabilities: false }));
264
268
  }
265
269
  return findings;
266
270
  }
package/dist/create.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { KIT_DEPENDENCY_RANGE } from "./version.js";
18
19
  // The stdlib range a fresh module declares tracks the stdlib it will compile
19
20
  // against: the vendored copy in a published kit, the workspace copy in the
20
21
  // monorepo. A hardcoded literal here went stale once (^0.3.0 outlived the
@@ -77,8 +78,7 @@ export default async function (ctx: ModuleRuntimeContext) {
77
78
  return `${header}
78
79
  import { client, createRegistrar } from "/modules/stdlib/mod.ts";
79
80
  import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
80
- import { React } from "/modules/stdlib/src/expose/React.ts";
81
- import { NavLink } from "/modules/stdlib/src/registers/navlink.tsx";
81
+ import { NavLink, React } from "/modules/stdlib/mod.ts";
82
82
  import { Button } from "/modules/stdlib/lib/primitives.js";
83
83
  import { nowPlaying } from "./logic.ts";
84
84
 
@@ -104,9 +104,7 @@ export default async function (ctx: ModuleRuntimeContext) {
104
104
  return `${header}
105
105
  import { client, createRegistrar } from "/modules/stdlib/mod.ts";
106
106
  import type { ModuleRuntimeContext } from "/modules/stdlib/mod.ts";
107
- import { React } from "/modules/stdlib/src/expose/React.ts";
108
- import { Platform } from "/modules/stdlib/src/expose/Platform.ts";
109
- import { TopbarRightButton } from "/modules/stdlib/src/registers/topbarRightButton.tsx";
107
+ import { Platform, React } from "/modules/stdlib/mod.ts";
110
108
  import { nowPlaying } from "./logic.ts";
111
109
 
112
110
  const ROUTE = "${route}";
@@ -123,10 +121,11 @@ const Page = () => (
123
121
  export default async function (ctx: ModuleRuntimeContext) {
124
122
  const registrar = createRegistrar(ctx);
125
123
 
126
- registrar.register(
127
- "topbarRightButton",
128
- <TopbarRightButton label="${name}" icon={ICON} onClick={() => Platform.getHistory().push(ROUTE)} />,
129
- );
124
+ registrar.placeButton("topbar-right", {
125
+ label: "${name}",
126
+ icon: ICON,
127
+ onClick: () => Platform.getHistory().push(ROUTE),
128
+ });
130
129
  registrar.registerRoute(ROUTE, <Page />);
131
130
  }
132
131
  `;
@@ -270,7 +269,7 @@ notification = 1C1C30
270
269
  dev: "spicetify-kit dev .",
271
270
  check: "spicetify-kit check .",
272
271
  },
273
- devDependencies: { "@spicetify/kit": "^0.1.0" },
272
+ devDependencies: { "@spicetify/kit": KIT_DEPENDENCY_RANGE },
274
273
  }, null, "\t")}\n`);
275
274
  writeFileSync(path.join(dir, ".gitignore"), "node_modules/\ndist/\n");
276
275
  }
@@ -374,7 +373,7 @@ export async function load(ctx: ModuleRuntimeContext) {
374
373
  test: 'node --test "test/*.test.mts"',
375
374
  },
376
375
  devDependencies: {
377
- "@spicetify/kit": "^0.1.0",
376
+ "@spicetify/kit": KIT_DEPENDENCY_RANGE,
378
377
  "@types/react": "^18",
379
378
  "@types/react-dom": "^18",
380
379
  "happy-dom": "^20",
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { cpSync, existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
15
15
  import path from "node:path";
16
+ import { KIT_DEPENDENCY_RANGE } from "./version.js";
16
17
  const USAGE = 'spicetify-kit from-theme <classic-theme-dir> [--name <id>] [--author "..."] [--bare]';
17
18
  export async function runFromTheme(argv, cwd = process.cwd()) {
18
19
  const source = argv.find((a) => !a.startsWith("--"));
@@ -76,7 +77,7 @@ export async function runFromTheme(argv, cwd = process.cwd()) {
76
77
  dev: "spicetify-kit dev .",
77
78
  },
78
79
  devDependencies: {
79
- "@spicetify/kit": "^0.1.0",
80
+ "@spicetify/kit": KIT_DEPENDENCY_RANGE,
80
81
  },
81
82
  }, null, "\t")}\n`);
82
83
  writeFileSync(path.join(dir, ".gitignore"), "node_modules/\ndist/\n");
@@ -0,0 +1,259 @@
1
+ /*
2
+ * Copyright (C) 2026 spicetify
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ import { readdirSync, readFileSync, statSync } from "node:fs";
6
+ import path from "node:path";
7
+ export const STDLIB_BOUNDARY_WARNING_RULES = ["ambient-client", "client-dom", "direct-map"];
8
+ const SOURCE_EXTENSIONS = new Set([".js", ".jsx", ".mjs", ".ts", ".tsx"]);
9
+ const STYLE_EXTENSIONS = new Set([".css", ".scss"]);
10
+ const PUBLIC_STDLIB_IMPORT = /^\/modules\/stdlib\/(?:mod\.(?:js|ts)|lib\/primitives(?:-classes|-vanilla)?\.(?:js|ts|tsx))$/;
11
+ const CLIENT_DOM_PATTERN = /(?:^|[^A-Za-z0-9_-])(?:Root__|main-[A-Za-z]|player-controls__|playback-progressbar|npv-)|\[data-testid(?:=|\])/;
12
+ const posix = (value) => value.split(path.sep).join("/");
13
+ const lineAt = (text, index) => text.slice(0, index).split("\n").length;
14
+ // Preserve string contents and character offsets while removing comments, so
15
+ // imports/selectors still match and diagnostics retain their original line.
16
+ function withoutComments(text) {
17
+ let out = "";
18
+ let state = "code";
19
+ let escaped = false;
20
+ for (let i = 0; i < text.length; i++) {
21
+ const char = text[i];
22
+ const next = text[i + 1];
23
+ if (state === "line") {
24
+ if (char === "\n") {
25
+ state = "code";
26
+ out += char;
27
+ }
28
+ else
29
+ out += " ";
30
+ continue;
31
+ }
32
+ if (state === "block") {
33
+ if (char === "*" && next === "/") {
34
+ out += " ";
35
+ i++;
36
+ state = "code";
37
+ }
38
+ else
39
+ out += char === "\n" ? "\n" : " ";
40
+ continue;
41
+ }
42
+ if (state === "code") {
43
+ if (char === "/" && next === "/") {
44
+ out += " ";
45
+ i++;
46
+ state = "line";
47
+ continue;
48
+ }
49
+ if (char === "/" && next === "*") {
50
+ out += " ";
51
+ i++;
52
+ state = "block";
53
+ continue;
54
+ }
55
+ if (char === "'")
56
+ state = "single";
57
+ else if (char === '"')
58
+ state = "double";
59
+ else if (char === "`")
60
+ state = "template";
61
+ out += char;
62
+ continue;
63
+ }
64
+ out += char;
65
+ if (escaped) {
66
+ escaped = false;
67
+ continue;
68
+ }
69
+ if (char === "\\") {
70
+ escaped = true;
71
+ continue;
72
+ }
73
+ if ((state === "single" && char === "'") ||
74
+ (state === "double" && char === '"') ||
75
+ (state === "template" && char === "`")) {
76
+ state = "code";
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ function firstMatch(source, pattern) {
82
+ pattern.lastIndex = 0;
83
+ return pattern.exec(source);
84
+ }
85
+ export function inspectStdlibBoundaryFile(file, text) {
86
+ const extension = path.extname(file);
87
+ if (!SOURCE_EXTENSIONS.has(extension) && !STYLE_EXTENSIONS.has(extension))
88
+ return [];
89
+ const normalizedFile = posix(file);
90
+ const source = withoutComments(text);
91
+ const findings = [];
92
+ if (SOURCE_EXTENSIONS.has(extension)) {
93
+ const imports = /(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)["'](\/modules\/stdlib\/[^"']+)["']/g;
94
+ for (const match of source.matchAll(imports)) {
95
+ if (PUBLIC_STDLIB_IMPORT.test(match[1]))
96
+ continue;
97
+ findings.push({
98
+ file: normalizedFile,
99
+ line: lineAt(source, match.index),
100
+ rule: "private-stdlib-import",
101
+ detail: `${match[1]} is private; import through /modules/stdlib/mod.js or the primitives kit`,
102
+ });
103
+ }
104
+ const ambient = firstMatch(source, /\bSpicetify\s*(?:\?\.|\.)|\b(?:globalThis|window)\s*\.\s*Spicetify\b/);
105
+ if (ambient) {
106
+ findings.push({
107
+ file: normalizedFile,
108
+ line: lineAt(source, ambient.index),
109
+ rule: "ambient-client",
110
+ detail: "imports should use stdlib's typed client capability instead of ambient Spicetify.*",
111
+ });
112
+ }
113
+ const directMap = firstMatch(source, /\bMAP\s*\./);
114
+ if (directMap) {
115
+ findings.push({
116
+ file: normalizedFile,
117
+ line: lineAt(source, directMap.index),
118
+ rule: "direct-map",
119
+ detail: "MAP.* is direct client coupling; prefer a semantic stdlib surface",
120
+ });
121
+ }
122
+ }
123
+ const clientDom = firstMatch(source, CLIENT_DOM_PATTERN);
124
+ if (clientDom) {
125
+ findings.push({
126
+ file: normalizedFile,
127
+ line: lineAt(source, clientDom.index),
128
+ rule: "client-dom",
129
+ detail: "Spotify-owned DOM selectors should be isolated and documented",
130
+ });
131
+ }
132
+ return findings;
133
+ }
134
+ function parseExceptions(meta) {
135
+ const configured = meta.stdlibBoundary;
136
+ if (configured === undefined)
137
+ return { exceptions: [], findings: [] };
138
+ const invalid = (message) => ({
139
+ severity: "error",
140
+ rule: "metadata.stdlib-boundary",
141
+ message,
142
+ file: "metadata.json",
143
+ });
144
+ if (typeof configured !== "object" || configured === null || Array.isArray(configured)) {
145
+ return { exceptions: [], findings: [invalid("stdlibBoundary must be an object")] };
146
+ }
147
+ const values = configured.exceptions;
148
+ if (!Array.isArray(values)) {
149
+ return { exceptions: [], findings: [invalid("stdlibBoundary.exceptions must be an array")] };
150
+ }
151
+ const exceptions = [];
152
+ const findings = [];
153
+ const allowedRules = new Set([
154
+ ...STDLIB_BOUNDARY_WARNING_RULES,
155
+ "missing-stdlib-dependency",
156
+ ]);
157
+ for (const [index, value] of values.entries()) {
158
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
159
+ findings.push(invalid(`stdlibBoundary.exceptions[${index}] must be an object`));
160
+ continue;
161
+ }
162
+ const candidate = value;
163
+ const validFile = typeof candidate.file === "string" &&
164
+ candidate.file.length > 0 &&
165
+ !candidate.file.includes("\\") &&
166
+ !path.posix.isAbsolute(candidate.file) &&
167
+ path.posix.normalize(candidate.file) === candidate.file &&
168
+ !candidate.file.startsWith("../");
169
+ const validRules = Array.isArray(candidate.rules) &&
170
+ candidate.rules.length > 0 &&
171
+ candidate.rules.every((rule) => typeof rule === "string" && allowedRules.has(rule));
172
+ if (!validFile || !validRules || typeof candidate.reason !== "string" || !candidate.reason.trim()) {
173
+ findings.push(invalid(`stdlibBoundary.exceptions[${index}] needs a normalized relative file, supported rules, and a reason`));
174
+ continue;
175
+ }
176
+ exceptions.push(candidate);
177
+ }
178
+ return { exceptions, findings };
179
+ }
180
+ function boundaryFiles(root) {
181
+ const files = [];
182
+ const walk = (directory) => {
183
+ for (const entry of readdirSync(directory)) {
184
+ if (["assets", "dist", "node_modules", "public"].includes(entry))
185
+ continue;
186
+ const full = path.join(directory, entry);
187
+ if (statSync(full).isDirectory())
188
+ walk(full);
189
+ else if (!/\.test\.[cm]?[jt]sx?$/.test(entry))
190
+ files.push(full);
191
+ }
192
+ };
193
+ walk(root);
194
+ return files;
195
+ }
196
+ export function checkExternalStdlibBoundary(root, metadata) {
197
+ if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))
198
+ return [];
199
+ const meta = metadata;
200
+ const parsed = parseExceptions(meta);
201
+ const out = [...parsed.findings];
202
+ const entries = meta.entries;
203
+ if (typeof entries?.js !== "string" || meta.name === "stdlib")
204
+ return out;
205
+ const isTheme = meta.kind === "theme" || (Array.isArray(meta.tags) && meta.tags.some((tag) => tag === "theme"));
206
+ const dependencies = meta.dependencies;
207
+ const missingDependency = !isTheme &&
208
+ typeof dependencies === "object" &&
209
+ dependencies !== null &&
210
+ !Array.isArray(dependencies) &&
211
+ typeof dependencies.stdlib !== "string";
212
+ const sources = boundaryFiles(root).flatMap((absolute) => {
213
+ const file = posix(path.relative(root, absolute));
214
+ return inspectStdlibBoundaryFile(file, readFileSync(absolute, "utf8"));
215
+ });
216
+ const exceptionAllows = (file, rule) => parsed.exceptions.some((item) => item.file === file && item.rules.includes(rule));
217
+ if (missingDependency && !exceptionAllows("metadata.json", "missing-stdlib-dependency")) {
218
+ out.push({
219
+ severity: "error",
220
+ rule: "stdlib-boundary.dependency",
221
+ message: "executable modules must declare a direct stdlib dependency",
222
+ file: "metadata.json",
223
+ });
224
+ }
225
+ for (const finding of sources) {
226
+ if (finding.rule === "private-stdlib-import") {
227
+ out.push({
228
+ severity: "error",
229
+ rule: "stdlib-boundary.private-import",
230
+ message: finding.detail,
231
+ file: `${finding.file}:${finding.line}`,
232
+ });
233
+ }
234
+ else if (!isTheme && !exceptionAllows(finding.file, finding.rule)) {
235
+ out.push({
236
+ severity: "warn",
237
+ rule: `stdlib-boundary.${finding.rule}`,
238
+ message: `${finding.detail}; document an exception only when the coupling is intrinsic`,
239
+ file: `${finding.file}:${finding.line}`,
240
+ });
241
+ }
242
+ }
243
+ for (const exception of parsed.exceptions) {
244
+ for (const rule of exception.rules) {
245
+ const present = rule === "missing-stdlib-dependency"
246
+ ? exception.file === "metadata.json" && missingDependency
247
+ : sources.some((finding) => finding.file === exception.file && finding.rule === rule);
248
+ if (!present) {
249
+ out.push({
250
+ severity: "error",
251
+ rule: "stdlib-boundary.stale-exception",
252
+ message: `${exception.file} no longer has ${rule}; remove the exception`,
253
+ file: "metadata.json",
254
+ });
255
+ }
256
+ }
257
+ }
258
+ return out;
259
+ }
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+ // Kept in lockstep with package.json by release-please. Scaffolds must depend
6
+ // on the kit version that generated them, especially across pre-1.0 minors.
7
+ export const KIT_VERSION = "0.2.0"; // x-release-please-version
8
+ export const KIT_DEPENDENCY_RANGE = `^${KIT_VERSION}`;
package/package.json CHANGED
@@ -1,49 +1,53 @@
1
1
  {
2
- "name": "@spicetify/kit",
3
- "version": "0.1.0",
4
- "description": "Developer kit for spicetify v3 modules: scaffold, build, hot-push, and pack",
5
- "license": "GPL-3.0-or-later",
6
- "homepage": "https://github.com/spicetify/modules#readme",
7
- "bugs": {
8
- "url": "https://github.com/spicetify/modules/issues"
9
- },
10
- "repository": {
11
- "type": "git",
12
- "url": "git+https://github.com/spicetify/modules.git",
13
- "directory": "packages/kit"
14
- },
15
- "type": "module",
16
- "bin": {
17
- "spicetify-kit": "bin/spicetify-kit.js"
18
- },
19
- "exports": {
20
- ".": "./dist/cli.js",
21
- "./create": "./dist/create.js",
22
- "./build": "./dist/build.js",
23
- "./dev": "./dist/dev.js",
24
- "./pack": "./dist/pack.js",
25
- "./from-theme": "./dist/from-theme.js",
26
- "./check": "./dist/check.js",
27
- "./vault": "./dist/vault.js",
28
- "./install": "./dist/install.js"
29
- },
30
- "files": [
31
- "bin",
32
- "dist",
33
- "vendor"
34
- ],
35
- "engines": {
36
- "node": ">=22"
37
- },
38
- "publishConfig": {
39
- "access": "public"
40
- },
41
- "dependencies": {
42
- "rolldown": "^1.2.0",
43
- "sass-embedded": "^1.80.0"
44
- },
45
- "scripts": {
46
- "build": "../../node_modules/.bin/tsc -p tsconfig.build.json",
47
- "test": "node --test \"test/*.test.mts\""
48
- }
49
- }
2
+ "name": "@spicetify/kit",
3
+ "version": "0.2.0",
4
+ "description": "Developer kit for spicetify v3 modules: scaffold, build, hot-push, and pack",
5
+ "license": "GPL-3.0-or-later",
6
+ "homepage": "https://github.com/spicetify/modules#readme",
7
+ "bugs": {
8
+ "url": "https://github.com/spicetify/modules/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/spicetify/modules.git",
13
+ "directory": "packages/kit"
14
+ },
15
+ "type": "module",
16
+ "bin": {
17
+ "spicetify-kit": "bin/spicetify-kit.js"
18
+ },
19
+ "exports": {
20
+ ".": "./dist/cli.js",
21
+ "./create": "./dist/create.js",
22
+ "./build": "./dist/build.js",
23
+ "./dev": "./dist/dev.js",
24
+ "./pack": "./dist/pack.js",
25
+ "./from-theme": "./dist/from-theme.js",
26
+ "./check": "./dist/check.js",
27
+ "./vault": "./dist/vault.js",
28
+ "./install": "./dist/install.js"
29
+ },
30
+ "files": [
31
+ "bin",
32
+ "dist",
33
+ "vendor"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "spicetify": {
42
+ "stdlibVersion": "1.10.0"
43
+ },
44
+ "scripts": {
45
+ "build": "tsc -p tsconfig.build.json",
46
+ "test": "node --test \"test/*.test.mts\"",
47
+ "prepack": "node scripts/sync-vendor.ts && tsc -p tsconfig.build.json"
48
+ },
49
+ "dependencies": {
50
+ "rolldown": "^1.2.0",
51
+ "sass-embedded": "^1.80.0"
52
+ }
53
+ }
@@ -508,16 +508,28 @@ declare namespace Spicetify {
508
508
  const CosmosAPI: any;
509
509
  /**
510
510
  * Reaches hosts that refuse the client's origin, through the local daemon
511
- * when it is running and the hosted proxy otherwise. Setting
512
- * `spicetify:corsProxyTemplate` replaces the chain with that one template.
511
+ * when it is running and the hosted proxy otherwise. Its global
512
+ * configuration API can replace the chain with one custom template.
513
513
  */
514
514
  namespace CORSProxy {
515
+ type Mode = "automatic" | "custom";
516
+ interface Configuration {
517
+ mode: Mode;
518
+ template: string | null;
519
+ automaticTemplates: [string, string];
520
+ }
515
521
  /** The proxied URL for `target` using the first template in the chain. */
516
522
  function url(target: string): string;
517
523
  /** Fetches `target` proxied, falling back to the next template only when a request gets no answer. */
518
524
  function fetch(target: string, options?: RequestInit): Promise<Response>;
519
525
  /** The active chain, in order of preference. */
520
526
  function templates(): string[];
527
+ /** Describes the global proxy mode without exposing its storage details. */
528
+ function configuration(): Configuration;
529
+ /** Applies the automatic chain or a validated custom URL template immediately. */
530
+ function configure(options: { mode: "automatic" } | { mode: "custom"; template: string }): Configuration;
531
+ /** True when a custom absolute URL template contains the required `{url}` placeholder. */
532
+ function isValidTemplate(template: string): boolean;
521
533
  }
522
534
  /**
523
535
  * Async wrappers of CosmosAPI
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ const FOCUSABLE_SELECTOR = [
7
+ "button:not([disabled])",
8
+ "[href]",
9
+ "input:not([disabled])",
10
+ "select:not([disabled])",
11
+ "textarea:not([disabled])",
12
+ '[tabindex]:not([tabindex="-1"])',
13
+ ].join(",");
14
+
15
+ const focusableElements = (dialog: HTMLElement): HTMLElement[] =>
16
+ [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
17
+ (element) => !element.hidden && element.getAttribute("aria-hidden") !== "true",
18
+ );
19
+
20
+ export function activateDialog(dialog: HTMLElement, close: () => void): () => void {
21
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
22
+ const onKeyDown = (event: KeyboardEvent) => {
23
+ if (event.key === "Escape") {
24
+ event.preventDefault();
25
+ event.stopPropagation();
26
+ close();
27
+ return;
28
+ }
29
+ if (event.key !== "Tab") return;
30
+ const focusable = focusableElements(dialog);
31
+ if (focusable.length === 0) {
32
+ event.preventDefault();
33
+ dialog.focus();
34
+ return;
35
+ }
36
+ const first = focusable[0];
37
+ const last = focusable.at(-1)!;
38
+ if (event.shiftKey && document.activeElement === first) {
39
+ event.preventDefault();
40
+ last.focus();
41
+ } else if (!event.shiftKey && document.activeElement === last) {
42
+ event.preventDefault();
43
+ first.focus();
44
+ }
45
+ };
46
+
47
+ document.addEventListener("keydown", onKeyDown, true);
48
+ (focusableElements(dialog)[0] ?? dialog).focus();
49
+ return () => {
50
+ document.removeEventListener("keydown", onKeyDown, true);
51
+ if (previousFocus?.isConnected) previousFocus.focus();
52
+ };
53
+ }
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Copyright (C) 2026 Afonso Jorge Ramos
3
+ * SPDX-License-Identifier: GPL-3.0-or-later
4
+ */
5
+
6
+ export interface AnchorRect {
7
+ left: number;
8
+ top: number;
9
+ right: number;
10
+ bottom: number;
11
+ }
12
+
13
+ export interface SurfaceSize {
14
+ width: number;
15
+ height: number;
16
+ }
17
+
18
+ export interface ViewportSize {
19
+ width: number;
20
+ height: number;
21
+ }
22
+
23
+ export interface FloatingPositionOptions {
24
+ align: "center" | "start";
25
+ gap: number;
26
+ margin?: number;
27
+ }
28
+
29
+ export interface FloatingPosition {
30
+ left: number;
31
+ top: number;
32
+ placement: "above" | "below";
33
+ }
34
+
35
+ const clamp = (value: number, minimum: number, maximum: number): number =>
36
+ Math.min(Math.max(value, minimum), Math.max(minimum, maximum));
37
+
38
+ export function calculateFloatingPosition(
39
+ anchor: AnchorRect,
40
+ surface: SurfaceSize,
41
+ viewport: ViewportSize,
42
+ options: FloatingPositionOptions,
43
+ ): FloatingPosition {
44
+ const margin = options.margin ?? 8;
45
+ const spaceBelow = viewport.height - anchor.bottom - margin;
46
+ const spaceAbove = anchor.top - margin;
47
+ const placement = spaceBelow >= surface.height + options.gap || spaceBelow >= spaceAbove ? "below" : "above";
48
+ const desiredTop = placement === "below" ? anchor.bottom + options.gap : anchor.top - options.gap - surface.height;
49
+ const anchorCenter = anchor.left + (anchor.right - anchor.left) / 2;
50
+ const desiredLeft = options.align === "center" ? anchorCenter - surface.width / 2 : anchor.left;
51
+
52
+ return {
53
+ left: Math.round(clamp(desiredLeft, margin, viewport.width - surface.width - margin)),
54
+ top: Math.round(clamp(desiredTop, margin, viewport.height - surface.height - margin)),
55
+ placement,
56
+ };
57
+ }