diff --git a/.gitignore b/.gitignore index a286ab9..436ea8c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ dist node_modules playwright-report -test-results \ No newline at end of file +test-results +.esbuild-serve \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md index d0af96c..da00116 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -179,7 +179,7 @@ recommend that a file or class name and description of purpose be included on the same “printed page” as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026, Regular Layout Authors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 289006b..d9db939 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,8 @@ Add the `` custom element to your HTML: ```html -
Main content
-
Sidebar content
+
Main content
+
Sidebar content
``` @@ -67,7 +67,7 @@ Create repositionable panels using ``: ```html - + Main content diff --git a/build.mjs b/build.ts similarity index 94% rename from build.mjs rename to build.ts index 387d769..fab0409 100644 --- a/build.mjs +++ b/build.ts @@ -14,7 +14,7 @@ import { execSync } from "node:child_process"; const watch = process.argv.includes("--watch"); -function generateDeclarations() { +function generateDeclarations(): void { console.log("Generating TypeScript declaration files..."); execSync( "pnpm tsc --project tsconfig.json", @@ -24,7 +24,7 @@ function generateDeclarations() { console.log("Declaration files generated!"); } -const browserConfig = { +const browserConfig: esbuild.BuildOptions = { entryPoints: ["src/index.ts"], bundle: true, minify: true, @@ -37,13 +37,13 @@ const browserConfig = { metafile: true, }; -function formatBytes(bytes) { +function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; } -async function build() { +async function build(): Promise { if (watch) { const browserContext = await esbuild.context(browserConfig); await browserContext.watch(); diff --git a/deploy.mjs b/deploy.mjs deleted file mode 100644 index 8c27e39..0000000 --- a/deploy.mjs +++ /dev/null @@ -1,77 +0,0 @@ -// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ -// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ -// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ -// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ -// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ -// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { execSync } from "node:child_process"; -import { cpSync, mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -function exec(command, options = {}) { - console.log(`> ${command}`); - return execSync(command, { stdio: "inherit", ...options }); -} - -function execOutput(command) { - return execSync(command, { encoding: "utf-8" }).trim(); -} - -try { - console.log("Checking git status..."); - const gitStatus = execOutput("git status --porcelain"); - if (gitStatus) { - console.error("Error: Git staging area is not clean. Please commit or stash your changes."); - console.error(gitStatus); - process.exit(1); - } - - console.log("Building project..."); - exec("pnpm run build"); - - console.log("Preparing deployment files..."); - const currentBranch = execOutput("git rev-parse --abbrev-ref HEAD"); - const currentCommit = execOutput("git rev-parse --short HEAD"); - const tempDir = mkdtempSync(join(tmpdir(), "gh-pages-")); - cpSync("dist", join(tempDir, "dist"), { recursive: true }); - cpSync("examples", tempDir, { recursive: true }); - - console.log("Switching to gh-pages branch..."); - let ghPagesExists = false; - try { - execSync("git show-ref --verify --quiet refs/heads/gh-pages"); - ghPagesExists = true; - } catch (e) { - throw new Error("No gh-pages branch found"); - } - - if (ghPagesExists) { - exec("git checkout gh-pages"); - exec("git add -A"); - exec("git stash"); - } else { - throw new Error("No gh-pages branch found"); - } - - console.log("Copying build artifacts..."); - cpSync(tempDir, ".", { recursive: true }); - console.log("Committing changes..."); - - exec("git add -A"); - exec(`git commit -m "Deploy from ${currentBranch} @ ${currentCommit}"`); - - console.log(`Returning to ${currentBranch}...`); - exec(`git stash pop`); - exec(`git checkout ${currentBranch}`); - rmSync(tempDir, { recursive: true, force: true }); - console.log("Deployment complete! gh-pages branch updated locally."); -} catch (error) { - console.error("Deployment failed:", error.message); - process.exit(1); -} diff --git a/deploy.ts b/deploy.ts new file mode 100644 index 0000000..fdcddc4 --- /dev/null +++ b/deploy.ts @@ -0,0 +1,94 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ +// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import * as esbuild from "esbuild"; +import { + copyFileSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +const ROOT_DIR = process.cwd(); +const EXAMPLES_DIR = join(ROOT_DIR, "examples"); + +async function deploy(): Promise { + console.log("Building examples for deployment..."); + + // Build the examples bundle + const result = await esbuild.build({ + entryPoints: [join(EXAMPLES_DIR, "index.ts")], + bundle: true, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + outfile: join(ROOT_DIR, "index.js"), + platform: "browser", + format: "esm", + sourcemap: true, + metafile: true, + }); + + if (result.metafile) { + console.log("\nBundle Size:"); + for (const [file, info] of Object.entries(result.metafile.outputs)) { + const bytes = info.bytes; + const size = + bytes < 1024 + ? `${bytes} B` + : bytes < 1024 * 1024 + ? `${(bytes / 1024).toFixed(2)} KB` + : `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + console.log(` ${file}: ${size}`); + } + } + + // Copy HTML file and update paths + console.log("\nCopying HTML file..."); + const htmlContent = readFileSync(join(EXAMPLES_DIR, "index.html"), "utf-8"); + const updatedHtml = htmlContent + .replace( + '', + '', + ) + .replace(/href="\.\.\/themes\//g, 'href="./'); + + writeFileSync(join(ROOT_DIR, "index.html"), updatedHtml); + + // Copy CSS file + console.log("\nCopying CSS file..."); + copyFileSync(join(EXAMPLES_DIR, "index.css"), join(ROOT_DIR, "index.css")); + + // Copy layout.json + console.log("\nCopying layout configuration..."); + copyFileSync( + join(EXAMPLES_DIR, "layout.json"), + join(ROOT_DIR, "layout.json"), + ); + + // Copy theme files + console.log("\nCopying theme files..."); + const themesDir = join(ROOT_DIR, "themes"); + const themeFiles = readdirSync(themesDir); + + for (const file of themeFiles) { + if (file.endsWith(".css")) { + copyFileSync(join(themesDir, file), join(ROOT_DIR, file)); + console.log(` Copied ${file}`); + } + } +} + +deploy().catch((error) => { + console.error("Deploy failed:", error); + process.exit(1); +}); diff --git a/examples/index.css b/examples/index.css index b529c25..7836541 100644 --- a/examples/index.css +++ b/examples/index.css @@ -1,7 +1,28 @@ +/* ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ + * ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ + * ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ + * ┃ * of the Regular Layout library, distributed under the terms of the * ┃ + * ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + body { + margin: 0px; font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; } +header { + padding: 0 12px; + gap: 12px; + display: flex; + align-items: center; + height: 36px; +} + /* Layout */ regular-layout { overflow: hidden; @@ -10,104 +31,25 @@ regular-layout { left: 0; right: 0; bottom: 0; + padding: 3px; + /* padding: 12px; */ + + /* + `regular-layout` supports the CSS `gap` property, but the "absolute" + mode overlay will not respect it (because there is no `gap` positioning + for absolutely positioned elements), leading to slightly off-center + drag/drop overlays. As "absolute" mode is required for CSS transitions + to work (making the smooth animation), you will need to use `margin` + instead of `gap` if you want smooth overlay animations _and_ correctly + positioned overlays. + */ + + /* margin: 3px; */ + /* gap: 6px; */ } /* Frame */ regular-layout-frame { - --titlebar-height: 24px; position: relative; box-sizing: border-box; - margin: 3px; - border-radius: 0 0 6px 6px; - border: 1px solid #666; - box-shadow: 0px 6px 6px -4px rgba(150, 150, 180); -} - -regular-layout-frame::part(titlebar) { - display: flex; - align-items: stretch; - margin-left: -1px; - margin-right: -1px; - margin-bottom: 0px; -} - -regular-layout-frame::part(tab) { - display: flex; - flex: 1 1 150px; - align-items: center; - font-size: 10px; - padding: 0 8px; - cursor: pointer; - max-width: 150px; - text-overflow: ellipsis; - border: 1px solid #666; - border-radius: 6px 6px 0 0; - opacity: 0.5; -} - -regular-layout-frame::part(active-tab) { - opacity: 1; -} - -/* Frame in Overlay Mode */ -regular-layout-frame.overlay { - background-color: rgba(0, 0, 0, 0.2) !important; - border: 1px dashed rgb(0, 0, 0); - border-radius: 6px; - margin: 0; - box-shadow: none; - transition: - top 0.1s ease-in-out, - height 0.1s ease-in-out, - width 0.1s ease-in-out, - left 0.1s ease-in-out; -} - -regular-layout-frame::part(container) { - display: none; } - -regular-layout-frame:not(.overlay)::part(container) { - display: revert; -} - -/* Colors */ -regular-layout :nth-child(8n+1), -regular-layout :nth-child(8n+1)::part(tab) { - background-color: #ffadadff; -} - -regular-layout :nth-child(8n+2), -regular-layout :nth-child(8n+2)::part(tab) { - background-color: #ffd6a5ff; -} - -regular-layout :nth-child(8n+3), -regular-layout :nth-child(8n+3)::part(tab) { - background-color: #fdffb6ff; -} - -regular-layout :nth-child(8n+4), -regular-layout :nth-child(8n+4)::part(tab) { - background-color: #caffbfff; -} - -regular-layout :nth-child(8n+5), -regular-layout :nth-child(8n+5)::part(tab) { - background-color: #9bf6ffff; -} - -regular-layout :nth-child(8n+6), -regular-layout :nth-child(8n+6)::part(tab) { - background-color: #a0c4ffff; -} - -regular-layout :nth-child(8n+7), -regular-layout :nth-child(8n+7)::part(tab) { - background-color: #bdb2ffff; -} - -regular-layout :nth-child(8n+8), -regular-layout :nth-child(8n+8)::part(tab) { - background-color: #ffc6ffff; -} \ No newline at end of file diff --git a/examples/index.html b/examples/index.html index 4a5c007..d4211bc 100644 --- a/examples/index.html +++ b/examples/index.html @@ -1,28 +1,42 @@ - - - - Regular Layout - - - - -
- - - - -
- - - - - - - - - - - - + + + + + Regular Layout + + + + + + + + + +
+ + + + + +
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/examples/index.js b/examples/index.ts similarity index 83% rename from examples/index.js rename to examples/index.ts index 668019a..a46f093 100644 --- a/examples/index.js +++ b/examples/index.ts @@ -9,12 +9,13 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import "/dist/index.js"; +import "../src/index.ts"; -const add = document.querySelector("#add"); -const save = document.querySelector("#save"); -const restore = document.querySelector("#restore"); -const clear = document.querySelector("#clear"); +const themes = document.querySelector("#themes") as HTMLSelectElement; +const add = document.querySelector("#add") as HTMLButtonElement; +const save = document.querySelector("#save") as HTMLButtonElement; +const restore = document.querySelector("#restore") as HTMLButtonElement; +const clear = document.querySelector("#clear") as HTMLButtonElement; add.addEventListener("click", () => { // Note: this *demo* implementation leaks `div` elements, because they // are not removed from the light DOM by `clear` or `restore`. You must @@ -27,16 +28,20 @@ add.addEventListener("click", () => { const COLORS = ["AAA", "BBB", "CCC", "DDD", "EEE", "FFF"]; const elem = document.createElement("regular-layout-frame"); - elem.setAttribute("slot", name); + elem.setAttribute("name", name); elem.classList.add(COLORS[Math.floor(COLORS.length * Math.random())]); layout.appendChild(elem); layout.insertPanel(name, []); }); +themes.addEventListener("change", (_event) => { + layout.className = themes.value; +}) + const req = await fetch("./layout.json"); let state = await req.json(); -const layout = document.querySelector("regular-layout"); +const layout = document.querySelector("regular-layout") as any; layout.restore(state); save.addEventListener("click", () => { state = layout.save(); diff --git a/package.json b/package.json index 000a909..e140c76 100644 --- a/package.json +++ b/package.json @@ -9,18 +9,20 @@ "url": "https://github.com/texodus/regular-layout" }, "browser": "dist/index.js", + "main": "dist/index.js", "type": "module", "files": [ "dist/**/*", - "src/**/*" + "src/**/*", + "themes/**/*" ], "scripts": { - "build": "node build.mjs", - "build:watch": "node build.mjs --watch", + "build": "tsx build.ts", + "build:watch": "tsx build.ts --watch", "clean": "rm -rf dist", "test": "playwright test", - "example": "npx http-server . -p 8000", - "deploy": "node deploy.mjs", + "example": "tsx serve.ts", + "deploy": "tsx deploy.ts", "lint": "biome lint src tests", "format": "biome format --write src tests", "check": "biome check --write src tests" @@ -30,7 +32,7 @@ "@playwright/test": "^1.57.0", "@types/node": "^22.10.5", "esbuild": "^0.27.2", - "http-server": "^14.1.1", + "tsx": "^4.21.0", "typescript": "^5.9.3" } } diff --git a/playwright.config.ts b/playwright.config.ts index 288130b..6c4b498 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,3 +1,14 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ +// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ @@ -19,7 +30,7 @@ export default defineConfig({ ], webServer: { reuseExistingServer: true, - command: "npx http-server . -p 8081", + command: "pnpm run example", url: "http://127.0.0.1:8081", }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d57e1c4..93a25c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,9 @@ importers: esbuild: specifier: ^0.27.2 version: 0.27.2 - http-server: - specifier: ^14.1.1 - version: 14.1.1 + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -246,157 +246,23 @@ packages: '@types/node@22.19.3': resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - async@3.2.6: - resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - - basic-auth@2.0.1: - resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} - engines: {node: '>= 0.8'} - - call-bind-apply-helpers@1.0.2: - resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - corser@2.0.1: - resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} - engines: {node: '>= 0.4.0'} - - debug@4.4.3: - resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - dunder-proto@1.0.1: - resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} - engines: {node: '>= 0.4'} - - es-define-property@1.0.1: - resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} - engines: {node: '>= 0.4'} - - es-errors@1.3.0: - resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} - engines: {node: '>= 0.4'} - - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} - engines: {node: '>= 0.4'} - esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} - - follow-redirects@1.15.11: - resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} - engines: {node: '>=4.0'} - peerDependencies: - debug: '*' - peerDependenciesMeta: - debug: - optional: true - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - - get-intrinsic@1.3.0: - resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} - engines: {node: '>= 0.4'} - - get-proto@1.0.1: - resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} - engines: {node: '>= 0.4'} - - gopd@1.2.0: - resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} - engines: {node: '>= 0.4'} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - has-symbols@1.1.0: - resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} - engines: {node: '>= 0.4'} - - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - - he@1.2.0: - resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} - hasBin: true - - html-encoding-sniffer@3.0.0: - resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} - engines: {node: '>=12'} - - http-proxy@1.18.1: - resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} - engines: {node: '>=8.0.0'} - - http-server@14.1.1: - resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==} - engines: {node: '>=12'} - hasBin: true - - iconv-lite@0.6.3: - resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} - engines: {node: '>=0.10.0'} - - math-intrinsics@1.1.0: - resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} - engines: {node: '>= 0.4'} - - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] - opener@1.5.2: - resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} - hasBin: true + get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} playwright-core@1.57.0: resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} @@ -408,45 +274,13 @@ packages: engines: {node: '>=18'} hasBin: true - portfinder@1.0.38: - resolution: {integrity: sha512-rEwq/ZHlJIKw++XtLAO8PPuOQA/zaPJOZJ37BVuN97nLpMJeuDVLVGRwbFoBgLudgdTMP2hdRJP++H+8QOA3vg==} - engines: {node: '>= 10.12'} - - qs@6.14.1: - resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} - engines: {node: '>=0.6'} - - requires-port@1.0.0: - resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} - - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} - - safer-buffer@2.1.2: - resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - - secure-compare@3.0.1: - resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} - - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} - - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} @@ -456,18 +290,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - union@0.5.0: - resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} - engines: {node: '>= 0.8.0'} - - url-join@4.0.1: - resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} - - whatwg-encoding@2.0.0: - resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} - engines: {node: '>=12'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation - snapshots: '@biomejs/biome@2.3.10': @@ -591,57 +413,6 @@ snapshots: dependencies: undici-types: 6.21.0 - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - async@3.2.6: {} - - basic-auth@2.0.1: - dependencies: - safe-buffer: 5.1.2 - - call-bind-apply-helpers@1.0.2: - dependencies: - es-errors: 1.3.0 - function-bind: 1.1.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - corser@2.0.1: {} - - debug@4.4.3: - dependencies: - ms: 2.1.3 - - dunder-proto@1.0.1: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-errors: 1.3.0 - gopd: 1.2.0 - - es-define-property@1.0.1: {} - - es-errors@1.3.0: {} - - es-object-atoms@1.1.1: - dependencies: - es-errors: 1.3.0 - esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -671,91 +442,15 @@ snapshots: '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 - eventemitter3@4.0.7: {} - - follow-redirects@1.15.11: {} - fsevents@2.3.2: optional: true - function-bind@1.1.2: {} - - get-intrinsic@1.3.0: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - function-bind: 1.1.2 - get-proto: 1.0.1 - gopd: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - math-intrinsics: 1.1.0 - - get-proto@1.0.1: - dependencies: - dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 - - gopd@1.2.0: {} - - has-flag@4.0.0: {} - - has-symbols@1.1.0: {} - - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - - he@1.2.0: {} - - html-encoding-sniffer@3.0.0: - dependencies: - whatwg-encoding: 2.0.0 - - http-proxy@1.18.1: - dependencies: - eventemitter3: 4.0.7 - follow-redirects: 1.15.11 - requires-port: 1.0.0 - transitivePeerDependencies: - - debug + fsevents@2.3.3: + optional: true - http-server@14.1.1: - dependencies: - basic-auth: 2.0.1 - chalk: 4.1.2 - corser: 2.0.1 - he: 1.2.0 - html-encoding-sniffer: 3.0.0 - http-proxy: 1.18.1 - mime: 1.6.0 - minimist: 1.2.8 - opener: 1.5.2 - portfinder: 1.0.38 - secure-compare: 3.0.1 - union: 0.5.0 - url-join: 4.0.1 - transitivePeerDependencies: - - debug - - supports-color - - iconv-lite@0.6.3: + get-tsconfig@4.13.0: dependencies: - safer-buffer: 2.1.2 - - math-intrinsics@1.1.0: {} - - mime@1.6.0: {} - - minimist@1.2.8: {} - - ms@2.1.3: {} - - object-inspect@1.13.4: {} - - opener@1.5.2: {} + resolve-pkg-maps: 1.0.0 playwright-core@1.57.0: {} @@ -765,67 +460,15 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - portfinder@1.0.38: - dependencies: - async: 3.2.6 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - - qs@6.14.1: - dependencies: - side-channel: 1.1.0 - - requires-port@1.0.0: {} - - safe-buffer@5.1.2: {} - - safer-buffer@2.1.2: {} - - secure-compare@3.0.1: {} + resolve-pkg-maps@1.0.0: {} - side-channel-list@1.0.0: + tsx@4.21.0: dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 - - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 + esbuild: 0.27.2 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 typescript@5.9.3: {} undici-types@6.21.0: {} - - union@0.5.0: - dependencies: - qs: 6.14.1 - - url-join@4.0.1: {} - - whatwg-encoding@2.0.0: - dependencies: - iconv-lite: 0.6.3 diff --git a/tests/integration/overlay-edge-detection.ts b/serve.ts similarity index 52% rename from tests/integration/overlay-edge-detection.ts rename to serve.ts index 565bd65..487e088 100644 --- a/tests/integration/overlay-edge-detection.ts +++ b/serve.ts @@ -9,56 +9,71 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { expect, test } from "@playwright/test"; -import { - getLayoutBounds, - hasClass, - setupLayout, -} from "../helpers/integration.ts"; -import { LAYOUTS } from "../helpers/fixtures.ts"; +import * as esbuild from "esbuild"; +import * as http from "node:http"; -test("should handle overlay near top edge of panel", async ({ page }) => { - await setupLayout(page, LAYOUTS.TWO_VERTICAL); - const bounds = await getLayoutBounds(page); +const PORT = 8081; - // Near top edge of AAA panel - const x = bounds.x + bounds.width * 0.5; - const y = bounds.y + bounds.height * 0.1; +const build_config: esbuild.BuildOptions = { + entryPoints: ["examples/index.ts"], + bundle: true, + outdir: ".esbuild-serve", + platform: "browser", + format: "esm", + sourcemap: true, + splitting: true, +}; - await page.evaluate( - ({ x, y }) => { - const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); - if (layoutPath) { - layout?.setOverlayState(x, y, layoutPath, "overlay", "absolute"); - } +async function serve(): Promise { + const ctx = await esbuild.context(build_config); + const { port } = await ctx.serve({ + servedir: ".", + onRequest: (args) => { + console.log(`${args.method} ${args.path} - ${args.status}`); }, - { x, y }, - ); + }); - const hasOverlayClass = await hasClass(page, "AAA", "overlay"); - expect(hasOverlayClass).toBe(true); -}); + const esbuild_host = "127.0.0.1"; + console.log(`esbuild server running on ${esbuild_host}:${port}`); + const proxy = http.createServer((req, res) => { + const options: http.RequestOptions = { + hostname: esbuild_host, + port: port, + path: req.url, + method: req.method, + headers: req.headers, + }; -test("should handle overlay near bottom edge of panel", async ({ page }) => { - await setupLayout(page, LAYOUTS.TWO_VERTICAL); - const bounds = await getLayoutBounds(page); + const proxy_req = http.request(options, (proxy_res) => { + if (proxy_res.statusCode === 404) { + res.writeHead(404, { "Content-Type": "text/html" }); + res.end("

404 - Not Found

"); + return; + } - // Near bottom edge of BBB panel - const x = bounds.x + bounds.width * 0.5; - const y = bounds.y + bounds.height * 0.9; + res.writeHead(proxy_res.statusCode || 200, proxy_res.headers); + proxy_res.pipe(res, { end: true }); + }); - await page.evaluate( - ({ x, y }) => { - const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); - if (layoutPath) { - layout?.setOverlayState(x, y, layoutPath, "overlay", "absolute"); - } - }, - { x, y }, - ); + proxy_req.on("error", (err) => { + console.error(`Proxy request error for ${req.url}:`, err.message); + res.writeHead(500, { "Content-Type": "text/html" }); + res.end("

500 - Internal Server Error

"); + }); + + req.pipe(proxy_req, { end: true }); + }); + + proxy.listen(PORT, () => { + console.log(`Development server running at http://localhost:${PORT}`); + console.log(`Serving directory: ${process.cwd()}`); + console.log("\nWatching for changes..."); + }); + + await ctx.watch(); +} - const hasOverlayClass = await hasClass(page, "BBB", "overlay"); - expect(hasOverlayClass).toBe(true); +serve().catch((error) => { + console.error(error); + process.exit(1); }); diff --git a/src/common/calculate_edge.ts b/src/common/calculate_edge.ts deleted file mode 100644 index 658858e..0000000 --- a/src/common/calculate_edge.ts +++ /dev/null @@ -1,104 +0,0 @@ -// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ -// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ -// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ -// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ -// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ -// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ -// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ -// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - -import { calculate_intersection } from "./calculate_intersect"; -import { SPLIT_EDGE_TOLERANCE } from "./constants"; -import { insert_child } from "./insert_child"; -import type { Layout, LayoutPath, Orientation } from "./layout_config"; - -/** - * Calculates an insertion point (which may involve splitting a single - * `"child-panel"` into a new `"split-panel"`), based on the cursor position. - * * - * @param col - The cursor column. - * @param row - The cursor row. - * @param panel - The `Layout` to insert into. - * @param slot - The slot identifier where the insert should occur - * @param drop_target - The `LayoutPath` (from `calculateIntersect`) of the - * panel to either insert next to, or split by. - * @returns A new `LayoutPath` reflecting the updated (maybe) `"split-panel"`, - * which is enough to draw the overlay. - */ -export function calculate_edge( - col: number, - row: number, - panel: Layout, - slot: string, - drop_target: LayoutPath, -): LayoutPath { - const is_column_edge = - drop_target.column_offset < SPLIT_EDGE_TOLERANCE || - drop_target.column_offset > 1 - SPLIT_EDGE_TOLERANCE; - - const is_row_edge = - drop_target.row_offset < SPLIT_EDGE_TOLERANCE || - drop_target.row_offset > 1 - SPLIT_EDGE_TOLERANCE; - - if (is_column_edge) { - return handle_axis( - col, - row, - panel, - slot, - drop_target, - drop_target.column_offset, - "horizontal", - ); - } else if (is_row_edge) { - return handle_axis( - col, - row, - panel, - slot, - drop_target, - drop_target.row_offset, - "vertical", - ); - } - - return drop_target; -} - -function handle_axis( - col: number, - row: number, - panel: Layout, - slot: string, - drop_target: LayoutPath, - axis_offset: number, - axis_orientation: Orientation, -): LayoutPath { - const is_before = axis_offset < SPLIT_EDGE_TOLERANCE; - if (drop_target.orientation === axis_orientation) { - if (drop_target.path.length === 0) { - const insert_index = is_before ? 0 : 1; - const new_panel = insert_child(panel, slot, [insert_index]); - drop_target = calculate_intersection(col, row, new_panel, false); - } else { - const path_without_last = drop_target.path.slice(0, -1); - const last_index = drop_target.path[drop_target.path.length - 1]; - const insert_index = is_before ? last_index : last_index + 1; - const new_panel = insert_child(panel, slot, [ - ...path_without_last, - insert_index, - ]); - - drop_target = calculate_intersection(col, row, new_panel, false); - } - } else { - const path = [...drop_target.path, is_before ? 0 : 1]; - const new_panel = insert_child(panel, slot, path, axis_orientation); - drop_target = calculate_intersection(col, row, new_panel, false); - } - - drop_target.is_edge = true; - return drop_target; -} diff --git a/src/extensions.ts b/src/extensions.ts index 269cb9a..e085a89 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -11,10 +11,12 @@ import { RegularLayout } from "./regular-layout.ts"; import { RegularLayoutFrame } from "./regular-layout-frame.ts"; -import type { Layout } from "./common/layout_config.ts"; +import type { Layout } from "./layout/types.ts"; +import { RegularLayoutTab } from "./regular-layout-tab.ts"; customElements.define("regular-layout", RegularLayout); customElements.define("regular-layout-frame", RegularLayoutFrame); +customElements.define("regular-layout-tab", RegularLayoutTab); declare global { interface Document { @@ -28,9 +30,15 @@ declare global { options?: ElementCreationOptions, ): RegularLayoutFrame; + createElement( + tagName: "regular-layout-tab", + options?: ElementCreationOptions, + ): RegularLayoutTab; + querySelector(selectors: string): E | null; querySelector(selectors: "regular-layout"): RegularLayout | null; querySelector(selectors: "regular-layout-frame"): RegularLayoutFrame | null; + querySelector(selectors: "regular-layout-tab"): RegularLayoutTab | null; } interface CustomElementRegistry { @@ -63,6 +71,4 @@ declare global { } } -export interface RegularLayoutEvent extends CustomEvent { - detail: Layout; -} +export type RegularLayoutEvent = CustomEvent; diff --git a/src/index.ts b/src/index.ts index b261137..e01ba50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,8 @@ * * ```html * - * Sidebar content - * Main content + * Sidebar content + * Main content * * ``` * @@ -59,11 +59,7 @@ * @packageDocumentation */ -export type { - LayoutPath, - Layout, - LayoutDivider, -} from "./common/layout_config.ts"; +export type * from "./layout/types.ts"; export { RegularLayout } from "./regular-layout.ts"; export { RegularLayoutFrame } from "./regular-layout-frame.ts"; diff --git a/src/layout/calculate_edge.ts b/src/layout/calculate_edge.ts new file mode 100644 index 0000000..0bea297 --- /dev/null +++ b/src/layout/calculate_edge.ts @@ -0,0 +1,209 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ +// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { SPLIT_EDGE_TOLERANCE, SPLIT_ROOT_EDGE_TOLERANCE } from "./constants"; +import { insert_child } from "./insert_child"; +import type { Layout, LayoutPath, Orientation, ViewWindow } from "./types"; + +/** + * Calculates an insertion point (which may involve splitting a single + * `"child-panel"` into a new `"split-panel"`), based on the cursor position. + * * + * @param col - The cursor column. + * @param row - The cursor row. + * @param panel - The `Layout` to insert into. + * @param slot - The slot identifier where the insert should occur + * @param drop_target - The `LayoutPath` (from `calculateIntersect`) of the + * panel to either insert next to, or split by. + * @returns A new `LayoutPath` reflecting the updated (maybe) `"split-panel"`, + * which is enough to draw the overlay. + */ +export function calculate_edge( + col: number, + row: number, + panel: Layout, + slot: string, + drop_target: LayoutPath, + box?: DOMRect, +): LayoutPath { + // Check root edges first + if (col < SPLIT_ROOT_EDGE_TOLERANCE) { + return insert_root_edge(panel, slot, drop_target, [0], true, "horizontal"); + } + + if (col > 1 - SPLIT_ROOT_EDGE_TOLERANCE) { + return insert_root_edge( + panel, + slot, + drop_target, + drop_target.path.length > 0 ? drop_target.path : [], + false, + "horizontal", + ); + } + + if (row < SPLIT_ROOT_EDGE_TOLERANCE) { + return insert_root_edge(panel, slot, drop_target, [0], true, "vertical"); + } + + if (row > 1 - SPLIT_ROOT_EDGE_TOLERANCE) { + return insert_root_edge(panel, slot, drop_target, [], false, "vertical"); + } + + // Check panel edges + const is_column_edge = + drop_target.column_offset < SPLIT_EDGE_TOLERANCE || + drop_target.column_offset > 1 - SPLIT_EDGE_TOLERANCE; + + const is_row_edge = + drop_target.row_offset < SPLIT_EDGE_TOLERANCE || + drop_target.row_offset > 1 - SPLIT_EDGE_TOLERANCE; + + // If both edges triggered, choose closer axis + if (is_column_edge && is_row_edge) { + const col_distance = Math.abs(drop_target.column_offset - 0.5); + const row_distance = Math.abs(drop_target.row_offset - 0.5); + const col_scale = + (box?.width || 1) * + (drop_target.view_window.col_end - drop_target.view_window.col_start); + + const row_scale = + (box?.height || 1) * + (drop_target.view_window.row_end - drop_target.view_window.row_start); + + const use_column = col_distance * col_scale > row_distance * row_scale; + + return insert_axis( + panel, + slot, + drop_target, + use_column + ? drop_target.column_offset < SPLIT_EDGE_TOLERANCE + : drop_target.row_offset < SPLIT_EDGE_TOLERANCE, + use_column ? "horizontal" : "vertical", + ); + } + + if (is_column_edge) { + return insert_axis( + panel, + slot, + drop_target, + drop_target.column_offset < SPLIT_EDGE_TOLERANCE, + "horizontal", + ); + } + + if (is_row_edge) { + return insert_axis( + panel, + slot, + drop_target, + drop_target.row_offset < SPLIT_EDGE_TOLERANCE, + "vertical", + ); + } + + // Not at an edge - insert as a tab + return { + ...drop_target, + path: [...drop_target.path, 0], + }; +} + +function insert_root_edge( + panel: Layout, + slot: string, + drop_target: LayoutPath, + path: number[], + is_before: boolean, + orientation: Orientation, +): LayoutPath { + return insert_axis( + panel, + slot, + { ...drop_target, path, orientation }, + is_before, + orientation, + ); +} + +function insert_axis( + panel: Layout, + slot: string, + drop_target: LayoutPath, + is_before: boolean, + axis_orientation: Orientation, +): LayoutPath { + let result_path: number[]; + + if (drop_target.orientation === axis_orientation) { + // Same orientation - insert into existing split + if (drop_target.path.length === 0) { + result_path = [is_before ? 0 : 1]; + } else { + const last_index = drop_target.path[drop_target.path.length - 1]; + result_path = [ + ...drop_target.path.slice(0, -1), + is_before ? last_index : last_index + 1, + ]; + } + } else { + // Different orientation - split the child panel + result_path = [...drop_target.path, is_before ? 0 : 1]; + } + + const new_panel = insert_child(panel, slot, result_path, axis_orientation); + const view_window = calculate_view_window(new_panel, result_path); + return { + ...drop_target, + path: result_path, + slot: drop_target.slot, + is_edge: true, + orientation: axis_orientation, + view_window, + }; +} + +function calculate_view_window(panel: Layout, path: number[]): ViewWindow { + let view_window: ViewWindow = { + row_start: 0, + row_end: 1, + col_start: 0, + col_end: 1, + }; + + let current_panel = panel; + for (const step of path) { + if (current_panel.type === "child-panel") { + break; + } + + const index = Math.min(step, current_panel.children.length - 1); + const is_vertical = current_panel.orientation === "vertical"; + const start_key = is_vertical ? "row_start" : "col_start"; + const end_key = is_vertical ? "row_end" : "col_end"; + const total_size = view_window[end_key] - view_window[start_key]; + const offset = current_panel.sizes + .slice(0, index) + .reduce((sum, size) => sum + size * total_size, view_window[start_key]); + + view_window = { + ...view_window, + [start_key]: offset, + [end_key]: offset + total_size * current_panel.sizes[index], + }; + + current_panel = current_panel.children[index]; + } + + return view_window; +} diff --git a/src/common/calculate_intersect.ts b/src/layout/calculate_intersect.ts similarity index 62% rename from src/common/calculate_intersect.ts rename to src/layout/calculate_intersect.ts index f593f0e..197f08b 100644 --- a/src/common/calculate_intersect.ts +++ b/src/layout/calculate_intersect.ts @@ -9,24 +9,25 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { - LayoutPath, - LayoutDivider, - Layout, - ViewWindow, -} from "./layout_config.ts"; +import { GRID_DIVIDER_SIZE } from "./constants.ts"; +import type { LayoutPath, LayoutDivider, Layout, ViewWindow } from "./types.ts"; + +const VIEW_WINDOW = { + row_start: 0, + row_end: 1, + col_start: 0, + col_end: 1, +}; /** * Determines which panel or divider is located at a given position in the * layout. * - * @param row - Vertical position as a fraction (0-1) of the container - * height - * @param column - Horizontal position as a fraction (0-1) of the - * container width + * @param column - Horizontal position as a fraction (0-1) of the container width + * @param row - Vertical position as a fraction (0-1) of the container height * @param layout - The layout tree to search * @param check_dividers - Whether `LayoutDivider` intersection should be - * checked, which oyu may not want for e.g. `drop` actions. + * checked, which you may not want for e.g. `drop` actions. * @returns The panel path if over a panel, a divider if over a resizable * boundary, or null if outside all panels */ @@ -34,44 +35,37 @@ export function calculate_intersection( column: number, row: number, layout: Layout, - check_dividers: false, -): LayoutPath; + check_dividers?: null, +): LayoutPath | null; export function calculate_intersection( column: number, row: number, layout: Layout, - check_dividers: true, + check_dividers?: DOMRect, ): LayoutPath | null | LayoutDivider; export function calculate_intersection( column: number, row: number, layout: Layout, - check_dividers?: boolean, + check_dividers?: DOMRect | null, ): LayoutPath | null | LayoutDivider; export function calculate_intersection( column: number, row: number, layout: Layout, - check_dividers: boolean = true, + check_dividers: DOMRect | null = null, ): LayoutPath | null | LayoutDivider { return calculate_intersection_recursive(column, row, layout, check_dividers); } -const VIEW_WINDOW = { - row_start: 0, - row_end: 1, - col_start: 0, - col_end: 1, -}; - function calculate_intersection_recursive( column: number, row: number, panel: Layout, - check_dividers: boolean, + check_dividers: DOMRect | null, parent_orientation: "horizontal" | "vertical" | null = null, view_window: ViewWindow = structuredClone(VIEW_WINDOW), path: number[] = [], @@ -83,104 +77,68 @@ function calculate_intersection_recursive( // Base case: if this is a child panel, return its name if (panel.type === "child-panel") { const selected = panel.selected ?? 0; - const column_offset = - (column - view_window.col_start) / - (view_window.col_end - view_window.col_start); - - const row_offset = - (row - view_window.row_start) / - (view_window.row_end - view_window.row_start); - + const col_width = view_window.col_end - view_window.col_start; + const row_height = view_window.row_end - view_window.row_start; return { type: "layout-path", - box: undefined, + layout: undefined, slot: panel.child[selected], - panel: structuredClone(panel), - path: path, - view_window: view_window, + path, + view_window, is_edge: false, column, row, - column_offset, - row_offset, + column_offset: (column - view_window.col_start) / col_width, + row_offset: (row - view_window.row_start) / row_height, orientation: parent_orientation || "horizontal", }; } // For split panels, determine which child was hit - if (panel.orientation === "vertical") { - // Vertical orientation = rows - let current_row = view_window.row_start; - for (let i = 0; i < panel.children.length; i++) { - const total_size = view_window.row_end - view_window.row_start; - const next_row = total_size * panel.sizes[i] + current_row; - if (check_dividers && Math.abs(row - next_row) < 0.01) { + const is_vertical = panel.orientation === "vertical"; + const position = is_vertical ? row : column; + const start_key = is_vertical ? "row_start" : "col_start"; + const end_key = is_vertical ? "row_end" : "col_end"; + const rect_dim = is_vertical ? check_dividers?.height : check_dividers?.width; + let current_pos = view_window[start_key]; + const total_size = view_window[end_key] - view_window[start_key]; + for (let i = 0; i < panel.children.length; i++) { + const next_pos = current_pos + total_size * panel.sizes[i]; + + // Check if position is on a divider + if (check_dividers && rect_dim) { + const divider_threshold = GRID_DIVIDER_SIZE / rect_dim; + if (Math.abs(position - next_pos) < divider_threshold) { return { path: [...path, i], - type: "vertical", + type: panel.orientation, view_window: { ...view_window, - row_start: current_row, - row_end: next_row, + [start_key]: current_pos, + [end_key]: next_pos, }, }; } - - if (row >= current_row && row < next_row) { - return calculate_intersection_recursive( - column, - row, - panel.children[i], - check_dividers, - "vertical", - { - ...view_window, - row_start: current_row, - row_end: next_row, - }, - [...path, i], - ); - } - - current_row = next_row; } - } else { - // Horizontal orientation = columns - let current_col = view_window.col_start; - for (let i = 0; i < panel.children.length; i++) { - const total_size = view_window.col_end - view_window.col_start; - const next_col = current_col + total_size * panel.sizes[i]; - if (check_dividers && Math.abs(column - next_col) < 0.01) { - return { - path: [...path, i], - type: "horizontal", - view_window: { - ...view_window, - col_start: current_col, - col_end: next_col, - }, - }; - } - - // Check if the column falls within this child's bounds. - if (column >= current_col && column < next_col) { - return calculate_intersection_recursive( - column, - row, - panel.children[i], - check_dividers, - "horizontal", - { - ...view_window, - col_start: current_col, - col_end: next_col, - }, - [...path, i], - ); - } - current_col = next_col; + // Check if position falls within this child's bounds + if (position >= current_pos && position < next_pos) { + return calculate_intersection_recursive( + column, + row, + panel.children[i], + check_dividers, + panel.orientation, + { + ...view_window, + [start_key]: current_pos, + [end_key]: next_pos, + }, + [...path, i], + ); } + + current_pos = next_pos; } // If we get here, the hit was outside all children (possibly in a gap or diff --git a/src/common/constants.ts b/src/layout/constants.ts similarity index 85% rename from src/common/constants.ts rename to src/layout/constants.ts index f14717f..c9c0689 100644 --- a/src/common/constants.ts +++ b/src/layout/constants.ts @@ -9,7 +9,13 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { OverlayMode } from "./layout_config"; +import type { OverlayMode } from "./types"; + +/** + * The prefix to use for `CustomEvent`s generated by `regular-layout`, e.g. + * `"regular-layout-before-update"`. + */ +export const CUSTOM_EVENT_NAME_PREFIX = "regular-layout"; /** * The minimum number of pixels the mouse must move to be considered a drag. @@ -32,6 +38,12 @@ export const MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD = 0.15; */ export const SPLIT_EDGE_TOLERANCE = 0.25; +/** + * Threshold from _container_ edge that is considered a split action on the root + * node. + */ +export const SPLIT_ROOT_EDGE_TOLERANCE = 0.01; + /** * Tolerance threshold for considering two grid track positions as identical. * @@ -44,3 +56,8 @@ export const GRID_TRACK_COLLAPSE_TOLERANCE = 0.001; * The overlay default behavior. */ export const OVERLAY_DEFAULT: OverlayMode = "absolute"; + +/** + * Width of split panel dividers in pixels (for hit-test purposes). + */ +export const GRID_DIVIDER_SIZE = 6; diff --git a/src/common/flatten.ts b/src/layout/flatten.ts similarity index 98% rename from src/common/flatten.ts rename to src/layout/flatten.ts index a0e3a71..cc99e0d 100644 --- a/src/common/flatten.ts +++ b/src/layout/flatten.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout } from "./layout_config.ts"; +import type { Layout } from "./types.ts"; /** * Flattens the layout tree by merging parent and child split panels that have diff --git a/src/common/generate_grid.ts b/src/layout/generate_grid.ts similarity index 58% rename from src/common/generate_grid.ts rename to src/layout/generate_grid.ts index a6780d2..12ace79 100644 --- a/src/common/generate_grid.ts +++ b/src/layout/generate_grid.ts @@ -10,8 +10,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { GRID_TRACK_COLLAPSE_TOLERANCE } from "./constants.ts"; -import type { Layout } from "./layout_config.ts"; -import { remove_child } from "./remove_child.ts"; +import type { Layout } from "./types.ts"; interface GridCell { child: string; @@ -21,26 +20,23 @@ interface GridCell { rowEnd: number; } -function dedupePositions(positions: number[]): number[] { - if (positions.length === 0) { - return []; - } - - const sorted = positions.sort((a, b) => a - b); - const result = [sorted[0]]; - for (let i = 1; i < sorted.length; i++) { - if ( - Math.abs(sorted[i] - result[result.length - 1]) > - GRID_TRACK_COLLAPSE_TOLERANCE - ) { - result.push(sorted[i]); - } +function dedupe_sort(result: number[], pos: number) { + if ( + result.length === 0 || + Math.abs(pos - result[result.length - 1]) > GRID_TRACK_COLLAPSE_TOLERANCE + ) { + result.push(pos); } return result; } -function collectTrackPositions( +function dedupe_positions(positions: number[]): number[] { + const sorted = positions.sort((a, b) => a - b); + return sorted.reduce(dedupe_sort, []); +} + +function collect_track_positions( panel: Layout, orientation: "horizontal" | "vertical", start: number, @@ -50,51 +46,44 @@ function collectTrackPositions( return [start, end]; } + const positions: number[] = [start, end]; if (panel.orientation === orientation) { - const positions: number[] = [start, end]; let current = start; + const range = end - start; for (let i = 0; i < panel.children.length; i++) { const size = panel.sizes[i]; - const childPositions = collectTrackPositions( - panel.children[i], - orientation, - current, - current + size * (end - start), + const next = current + size * range; + positions.push( + ...collect_track_positions( + panel.children[i], + orientation, + current, + next, + ), ); - positions.push(...childPositions); - current = current + size * (end - start); + current = next; } - - return dedupePositions(positions); } else { - const allPositions: number[] = [start, end]; for (const child of panel.children) { - const childPositions = collectTrackPositions( - child, - orientation, - start, - end, + positions.push( + ...collect_track_positions(child, orientation, start, end), ); - - allPositions.push(...childPositions); } - - return dedupePositions(allPositions); } + + return dedupe_positions(positions); } -function findTrackIndex(positions: number[], value: number): number { - for (let i = 0; i < positions.length; i++) { - if (Math.abs(positions[i] - value) < GRID_TRACK_COLLAPSE_TOLERANCE) { - return i; - } - } +function find_track_index(positions: number[], value: number): number { + const index = positions.findIndex( + (pos) => Math.abs(pos - value) < GRID_TRACK_COLLAPSE_TOLERANCE, + ); - throw new Error(`Position ${value} not found in ${positions}`); + return index === -1 ? 0 : index; } -function buildCells( +function build_cells( panel: Layout, colPositions: number[], rowPositions: number[], @@ -108,22 +97,24 @@ function buildCells( return [ { child: panel.child[selected], - colStart: findTrackIndex(colPositions, colStart), - colEnd: findTrackIndex(colPositions, colEnd), - rowStart: findTrackIndex(rowPositions, rowStart), - rowEnd: findTrackIndex(rowPositions, rowEnd), + colStart: find_track_index(colPositions, colStart), + colEnd: find_track_index(colPositions, colEnd), + rowStart: find_track_index(rowPositions, rowStart), + rowEnd: find_track_index(rowPositions, rowEnd), }, ]; } - const cells: GridCell[] = []; const { children, sizes, orientation } = panel; - if (orientation === "horizontal") { - let current = colStart; - for (let i = 0; i < children.length; i++) { - const next = current + sizes[i] * (colEnd - colStart); + const isHorizontal = orientation === "horizontal"; + let current = isHorizontal ? colStart : rowStart; + const range = isHorizontal ? colEnd - colStart : rowEnd - rowStart; + const cells: GridCell[] = []; + for (let i = 0; i < children.length; i++) { + const next = current + sizes[i] * range; + if (isHorizontal) { cells.push( - ...buildCells( + ...build_cells( children[i], colPositions, rowPositions, @@ -133,15 +124,9 @@ function buildCells( rowEnd, ), ); - - current = next; - } - } else { - let current = rowStart; - for (let i = 0; i < children.length; i++) { - const next = current + sizes[i] * (rowEnd - rowStart); + } else { cells.push( - ...buildCells( + ...build_cells( children[i], colPositions, rowPositions, @@ -151,19 +136,19 @@ function buildCells( next, ), ); - - current = next; } + + current = next; } return cells; } const host_template = (rowTemplate: string, colTemplate: string) => - `:host { display: grid; gap: 0px; grid-template-rows: ${rowTemplate}; grid-template-columns: ${colTemplate}; }`; + `:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:${rowTemplate};grid-template-columns:${colTemplate}}`; const child_template = (slot: string, rowPart: string, colPart: string) => - `:host ::slotted([slot=${slot}]) { grid-column: ${colPart}; grid-row: ${rowPart}; }`; + `:host ::slotted([name="${slot}"]){display:flex;grid-column:${colPart};grid-row:${rowPart}}`; /** * Generates CSS Grid styles to render a layout tree. @@ -191,8 +176,8 @@ const child_template = (slot: string, rowPart: string, colPart: string) => * const css = create_css_grid_layout(layout); * // Returns CSS like: * // :host { display: grid; grid-template-columns: 25% 75%; ... } - * // :host ::slotted([slot=sidebar]) { grid-column: 1; grid-row: 1; } - * // :host ::slotted([slot=main]) { grid-column: 2; grid-row: 1; } + * // :host ::slotted([name=sidebar]) { grid-column: 1; grid-row: 1; } + * // :host ::slotted([name=main]) { grid-column: 2; grid-row: 1; } * ``` */ export function create_css_grid_layout( @@ -200,51 +185,36 @@ export function create_css_grid_layout( round: boolean = false, overlay?: [string, string], ): string { - if (overlay) { - layout = remove_child(layout, overlay[0]); - } - if (layout.type === "child-panel") { const selected = layout.selected ?? 0; return `${host_template("100%", "100%")}\n${child_template(layout.child[selected], "1", "1")}`; } - const colPositions = collectTrackPositions(layout, "horizontal", 0, 1); - const colSizes: number[] = []; - for (let i = 0; i < colPositions.length - 1; i++) { - colSizes.push(colPositions[i + 1] - colPositions[i]); - } - - const colTemplate = colSizes - .map((s) => `${round ? Math.round(s * 100) : s * 100}%`) - .join(" "); - - const rowPositions = collectTrackPositions(layout, "vertical", 0, 1); - const rowSizes: number[] = []; - for (let i = 0; i < rowPositions.length - 1; i++) { - rowSizes.push(rowPositions[i + 1] - rowPositions[i]); - } - - const rowTemplate = rowSizes - .map((s) => `${round ? Math.round(s * 100) : s * 100}%`) - .join(" "); - - const cells = buildCells(layout, colPositions, rowPositions, 0, 1, 0, 1); + const createTemplate = (positions: number[]) => { + const sizes = positions + .slice(0, -1) + .map((pos, i) => positions[i + 1] - pos); + return sizes + .map((s) => `${round ? Math.round(s * 100) : s * 100}fr`) + .join(" "); + }; + + const colPositions = collect_track_positions(layout, "horizontal", 0, 1); + const colTemplate = createTemplate(colPositions); + const rowPositions = collect_track_positions(layout, "vertical", 0, 1); + const rowTemplate = createTemplate(rowPositions); + const formatGridLine = (start: number, end: number) => + end - start === 1 ? `${start + 1}` : `${start + 1} / ${end + 1}`; + + const cells = build_cells(layout, colPositions, rowPositions, 0, 1, 0, 1); const css = [host_template(rowTemplate, colTemplate)]; for (const cell of cells) { - const colPart = - cell.colEnd - cell.colStart === 1 - ? `${cell.colStart + 1}` - : `${cell.colStart + 1} / ${cell.colEnd + 1}`; - const rowPart = - cell.rowEnd - cell.rowStart === 1 - ? `${cell.rowStart + 1}` - : `${cell.rowStart + 1} / ${cell.rowEnd + 1}`; - - css.push(`${child_template(cell.child, rowPart, colPart)}`); + const colPart = formatGridLine(cell.colStart, cell.colEnd); + const rowPart = formatGridLine(cell.rowStart, cell.rowEnd); + css.push(child_template(cell.child, rowPart, colPart)); if (cell.child === overlay?.[1]) { - css.push(`${child_template(overlay[0], rowPart, colPart)}`); - css.push(`:host ::slotted([slot=${overlay[0]}]) { z-index: 1; }`); + css.push(child_template(overlay[0], rowPart, colPart)); + css.push(`:host ::slotted([name=${overlay[0]}]){z-index:1}`); } } diff --git a/src/common/generate_overlay.ts b/src/layout/generate_overlay.ts similarity index 70% rename from src/common/generate_overlay.ts rename to src/layout/generate_overlay.ts index 2845ae7..67510c9 100644 --- a/src/common/generate_overlay.ts +++ b/src/layout/generate_overlay.ts @@ -9,20 +9,32 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { LayoutPath } from "./layout_config"; +import type { LayoutPath } from "./types"; export function updateOverlaySheet( slot: string, - { - view_window: { row_start, row_end, col_start, col_end }, - box, - }: LayoutPath, + box: DOMRect, + style: CSSStyleDeclaration, + drag_target: LayoutPath | null, ) { - const margin = 0; - const top = row_start * box.height + margin / 2; - const left = col_start * box.width + margin / 2; - const height = (row_end - row_start) * box.height - margin; - const width = (col_end - col_start) * box.width - margin; - const css = `position:absolute!important;z-index:1;top:${top}px;left:${left}px;height:${height}px;width:${width}px;`; - return `::slotted([slot="${slot}"]){${css}}`; + if (!drag_target) { + return `:host ::slotted([name="${slot}"]){display:none;}`; + } + + const { + view_window: { row_start, row_end, col_start, col_end }, + } = drag_target; + + const box_height = + box.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom); + + const box_width = + box.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + + const top = row_start * box_height + parseFloat(style.paddingTop); + const left = col_start * box_width + parseFloat(style.paddingLeft); + const height = (row_end - row_start) * box_height; + const width = (col_end - col_start) * box_width; + const css = `display:flex;position:absolute!important;z-index:1;top:${top}px;left:${left}px;height:${height}px;width:${width}px;`; + return `::slotted([name="${slot}"]){${css}}`; } diff --git a/src/common/insert_child.ts b/src/layout/insert_child.ts similarity index 51% rename from src/common/insert_child.ts rename to src/layout/insert_child.ts index a6ca2b1..6ef52bc 100644 --- a/src/common/insert_child.ts +++ b/src/layout/insert_child.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout } from "./layout_config.ts"; +import type { Layout } from "./types.ts"; /** * Inserts a new child panel into the layout tree at a specified location. @@ -22,15 +22,20 @@ import type { Layout } from "./layout_config.ts"; * at root level. * @param orientation - Orientation for newly created split panels. Defaults to * "horizontal". + * @param is_edge - If true, create the split at the parent level. * @returns A new layout tree with the child inserted (original is not mutated). */ export function insert_child( panel: Layout, child: string, path: number[], - orientation: "horizontal" | "vertical" = "horizontal", - is_edge?: boolean, + orientation?: "horizontal" | "vertical", ): Layout { + const createChildPanel = (childId: string): Layout => ({ + type: "child-panel", + child: [childId], + }); + if (path.length === 0) { // Insert at root level if (panel.type === "child-panel") { @@ -39,92 +44,136 @@ export function insert_child( type: "child-panel", child: [child, ...panel.child], }; + } else if (orientation) { + // When inserting at edge of root, wrap the entire panel in a new split + return { + type: "split-panel", + orientation: orientation, + children: [createChildPanel(child), panel], + sizes: [0.5, 0.5], + }; } else { // Append to existing split-panel - const newChildren = [ - ...panel.children, - { - type: "child-panel", - child: [child], - } as Layout, - ]; - - const numChildren = newChildren.length; - const newSizes = Array(numChildren).fill(1 / numChildren); + const newChildren = [...panel.children, createChildPanel(child)]; + const newSizes = [...panel.sizes, 1 / (newChildren.length - 1)]; return { ...panel, children: newChildren, - sizes: newSizes, + sizes: redistribute(newSizes), }; } } // Navigate down the path const [index, ...restPath] = path; + + // Special case: when orientation is provided and restPath is empty, handle edge insertion + if (orientation && restPath.length === 0) { + // If panel is a split-panel with the same orientation, insert into its children + if (panel.type === "split-panel" && panel.orientation === orientation) { + const newChildren = [...panel.children]; + newChildren.splice(index, 0, createChildPanel(child)); + const newSizes = [...panel.sizes]; + newSizes.splice(index, 0, 1 / (newChildren.length - 1)); + return { + ...panel, + children: newChildren, + sizes: redistribute(newSizes), + }; + } + + // Otherwise, wrap the entire panel in a new split at the edge + const children = + index === 0 + ? [createChildPanel(child), panel] + : [panel, createChildPanel(child)]; + + return { + type: "split-panel", + orientation: orientation, + children, + sizes: [0.5, 0.5], + }; + } + if (panel.type === "child-panel") { - // This shouldn't happen if path.length > 0, but handle it gracefully - // We need to split this child-panel + // Stack into child array only when ALL of these conditions are met: + // 1. Path has exactly one element (restPath is empty) + // 2. Orientation was NOT explicitly provided (orientation is undefined) + // 3. Index is within the valid stacking range [0, child.length] + if ( + restPath.length === 0 && + orientation === undefined && + index >= 0 && + index <= panel.child.length + ) { + const newChild = [...panel.child]; + newChild.splice(index, 0, child); + return { + ...panel, + child: newChild, + }; + } + + // Otherwise, wrap in a split panel and recurse const newPanel: Layout = { type: "split-panel", - orientation, + orientation: orientation || "horizontal", children: [panel], sizes: [1], }; - return insert_child(newPanel, child, path, orientation, is_edge); + return insert_child(newPanel, child, path, orientation); } if (restPath.length === 0 || index === panel.children.length) { - if (is_edge && panel.children[index]?.type === "child-panel") { - panel.children[index].child.unshift(child); - panel.children[index].selected = 0; - return panel; + if (orientation && panel.children[index]) { + // When inserting at an edge, create a split panel with the new child and existing child + const newSplitPanel: Layout = { + type: "split-panel", + orientation: orientation, + children: [createChildPanel(child), panel.children[index]], + sizes: [0.5, 0.5], + }; + + const newChildren = [...panel.children]; + newChildren[index] = newSplitPanel; + return { + ...panel, + children: newChildren, + sizes: redistribute(panel.sizes), + }; } // Insert at this level at the specified index const newChildren = [...panel.children]; - newChildren.splice(index, 0, { - type: "child-panel", - child: [child], - }); - - const numChildren = newChildren.length; - const newSizes = Array(numChildren).fill(1 / numChildren); + newChildren.splice(index, 0, createChildPanel(child)); + const newSizes = [...panel.sizes]; + newSizes.splice(index, 0, 1 / (newChildren.length - 1)); return { ...panel, children: newChildren, - sizes: newSizes, + sizes: redistribute(newSizes), }; } const targetChild = panel.children[index]; - if (targetChild.type === "child-panel" && restPath.length > 0) { - // Need to split this child-panel - const oppositeOrientation = - panel.orientation === "horizontal" ? "vertical" : "horizontal"; - - const newSplitPanel = insert_child( - targetChild, - child, - restPath, - oppositeOrientation, - is_edge, - ); - const newChildren = [...panel.children]; - newChildren[index] = newSplitPanel; - return { - ...panel, - children: newChildren, - }; - } + // Determine the orientation to pass down when navigating into a child-panel + const childOrientation = + targetChild.type === "child-panel" && + restPath.length > 0 && + orientation !== undefined + ? panel.orientation === "horizontal" + ? "vertical" + : "horizontal" + : orientation; const updatedChild = insert_child( targetChild, child, restPath, - orientation, - is_edge, + childOrientation, ); const newChildren = [...panel.children]; @@ -134,3 +183,8 @@ export function insert_child( children: newChildren, }; } + +function redistribute(arr: number[]): number[] { + const total = arr.reduce((sum, val) => sum + val, 0); + return arr.map((val) => val / total); +} diff --git a/src/common/redistribute_panel_sizes.ts b/src/layout/redistribute_panel_sizes.ts similarity index 98% rename from src/common/redistribute_panel_sizes.ts rename to src/layout/redistribute_panel_sizes.ts index a39bc09..a5bcf70 100644 --- a/src/common/redistribute_panel_sizes.ts +++ b/src/layout/redistribute_panel_sizes.ts @@ -10,7 +10,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { MINIMUM_REDISTRIBUTION_SIZE_THRESHOLD } from "./constants.ts"; -import type { Layout } from "./layout_config.ts"; +import type { Layout } from "./types.ts"; /** * Adjusts panel sizes during a drag operation on a divider. diff --git a/src/common/remove_child.ts b/src/layout/remove_child.ts similarity index 97% rename from src/common/remove_child.ts rename to src/layout/remove_child.ts index dcf5ec9..c8ba4a1 100644 --- a/src/common/remove_child.ts +++ b/src/layout/remove_child.ts @@ -9,8 +9,8 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout, TabLayout } from "./layout_config.ts"; -import { EMPTY_PANEL } from "./layout_config.ts"; +import type { Layout, TabLayout } from "./types.ts"; +import { EMPTY_PANEL } from "./types.ts"; /** * Removes a child panel from the layout tree by its name. diff --git a/src/common/layout_config.ts b/src/layout/types.ts similarity index 88% rename from src/common/layout_config.ts rename to src/layout/types.ts index 11a8e06..7b7c5e6 100644 --- a/src/common/layout_config.ts +++ b/src/layout/types.ts @@ -40,6 +40,11 @@ export interface ViewWindow { * Child panels are arranged either horizontally (side by side) or vertically * (stacked), via the `orientation` property `"horizzontal"` and `"vertical"` * (respectively). + * + * While the type structure of `SplitLayout` allows nesting levels with the same + * `orientation`, calling `RegularLayout.restore` with such a `Layout` will be + * flattened to the equivalent layout with every child guaranteed to have the + * opposite `orientation` as its parent. */ export interface SplitLayout { type: "split-panel"; @@ -78,7 +83,6 @@ export interface LayoutDivider { export interface LayoutPath { type: "layout-path"; slot: string; - panel: TabLayout; path: number[]; view_window: ViewWindow; column: number; @@ -87,24 +91,7 @@ export interface LayoutPath { row_offset: number; orientation: Orientation; is_edge: boolean; - box: T; -} - -/** - * Recursively iterates over all child panel names in the layout tree, yielding - * panel names in depth-first order. - * - * @param panel - The layout tree to iterate over - * @returns Generator yielding child panel names - */ -export function* iter_panel_children(panel: Layout): Generator { - if (panel.type === "split-panel") { - for (const child of panel.children) { - yield* iter_panel_children(child); - } - } else { - yield panel.child[panel.selected || 0]; - } + layout: T; } /** diff --git a/src/regular-layout-frame.ts b/src/regular-layout-frame.ts index ac8cd0c..c040638 100644 --- a/src/regular-layout-frame.ts +++ b/src/regular-layout-frame.ts @@ -9,20 +9,26 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import { MIN_DRAG_DISTANCE, OVERLAY_CLASSNAME } from "./common/constants.ts"; -import type { LayoutPath, TabLayout } from "./common/layout_config.ts"; +import { MIN_DRAG_DISTANCE, OVERLAY_CLASSNAME } from "./layout/constants.ts"; +import type { Layout, LayoutPath } from "./layout/types.ts"; import type { RegularLayoutEvent } from "./extensions.ts"; import type { RegularLayout } from "./regular-layout.ts"; - -const CSS = (className: string) => ` -:host{--titlebar--height:24px;box-sizing:border-box} -:host(:not(.${className})){margin-top:calc(var(--titlebar--height) + 3px)!important;} -:host(:not(.${className}))::part(container){position:absolute;top:0;left:0;right:0;bottom:0;display:flex;flex-direction:column;background-color:inherit;border-radius:inherit} -:host(:not(.${className}))::part(titlebar){height:var(--titlebar--height);margin-top:calc(0px - var(--titlebar--height));user-select: none;} -:host(:not(.${className}))::part(body){flex:1 1 auto;} +import type { RegularLayoutTab } from "./regular-layout-tab.ts"; + +const CSS = ` +:host{box-sizing:border-box;flex-direction:column} +:host::part(titlebar){display:flex;height:24px;user-select:none;overflow:hidden} +:host::part(container){flex:1 1 auto} +:host::part(title){flex:1 1 auto;pointer-events:none} +:host::part(close){align-self:stretch} +:host::slotted{flex:1 1 auto;} +:host regular-layout-tab{width:0px;} `; -const HTML_TEMPLATE = ``; +const HTML_TEMPLATE = ` +
+ +`; /** * A custom element that represents a draggable panel within a @@ -43,7 +49,7 @@ const HTML_TEMPLATE = ` - * + * * * *
@@ -54,14 +60,13 @@ export class RegularLayoutFrame extends HTMLElement { private _container_sheet: CSSStyleSheet; private _layout!: RegularLayout; private _header!: HTMLElement; - private _drag_state: LayoutPath | null = null; + private _drag_state: LayoutPath | null = null; private _drag_moved: boolean = false; - private _tab_to_index_map: WeakMap = new WeakMap(); - private _tab_panel_state: TabLayout | null = null; + private _tab_to_index_map: WeakMap = new WeakMap(); constructor() { super(); this._container_sheet = new CSSStyleSheet(); - this._container_sheet.replaceSync(CSS(OVERLAY_CLASSNAME)); + this._container_sheet.replaceSync(CSS); this._shadowRoot = this.attachShadow({ mode: "open" }); this._shadowRoot.adoptedStyleSheets = [this._container_sheet]; } @@ -69,7 +74,7 @@ export class RegularLayoutFrame extends HTMLElement { connectedCallback() { this._shadowRoot.innerHTML = HTML_TEMPLATE; this._layout = this.parentElement as RegularLayout; - this._header = this._shadowRoot.children[0].children[0] as HTMLElement; + this._header = this._shadowRoot.children[0] as HTMLElement; this._header.addEventListener("pointerdown", this.onPointerDown); this._header.addEventListener("pointermove", this.onPointerMove); this._header.addEventListener("pointerup", this.onPointerUp); @@ -94,7 +99,7 @@ export class RegularLayoutFrame extends HTMLElement { } private onPointerDown = (event: PointerEvent): void => { - const elem = event.target as HTMLDivElement; + const elem = event.target as RegularLayoutTab; if (elem.part.contains("tab")) { this._drag_state = this._layout.calculateIntersect( event.clientX, @@ -104,11 +109,6 @@ export class RegularLayoutFrame extends HTMLElement { if (this._drag_state) { this._header.setPointerCapture(event.pointerId); event.preventDefault(); - const last_index = this._drag_state.path.length - 1; - const selected = this._tab_to_index_map.get(elem); - if (selected) { - this._drag_state.path[last_index] = selected; - } } } }; @@ -165,35 +165,30 @@ export class RegularLayoutFrame extends HTMLElement { }; private drawTabs = (event: RegularLayoutEvent) => { - const slot = this.assignedSlot; + const slot = this.getAttribute("name"); if (!slot) { return; } const new_panel = event.detail; - const new_tab_panel = this._layout.getPanel(slot.name, new_panel); + let new_tab_panel = this._layout.getPanel(slot, new_panel); if (!new_tab_panel) { - return; + new_tab_panel = { + type: "child-panel", + child: [slot], + selected: 0, + }; } for (let i = 0; i < new_tab_panel.child.length; i++) { if (i >= this._header.children.length) { - const new_tab = this.createTab(new_tab_panel, i); + const new_tab = document.createElement("regular-layout-tab"); + new_tab.populate(this._layout, new_tab_panel, i); this._header.appendChild(new_tab); + this._tab_to_index_map.set(new_tab, i); } else { - const tab_changed = - (i === new_tab_panel.selected) !== - (i === this._tab_panel_state?.selected); - - const tab = this._header.children[i] as HTMLDivElement; - const index_changed = - tab_changed || - this._tab_panel_state?.child[i] !== new_tab_panel.child[i]; - - if (index_changed) { - const new_tab = this.createTab(new_tab_panel, i); - this._header.replaceChild(new_tab, tab); - } + const tab = this._header.children[i] as RegularLayoutTab; + tab.populate(this._layout, new_tab_panel, i); } } @@ -201,37 +196,5 @@ export class RegularLayoutFrame extends HTMLElement { for (let j = this._header.children.length - 1; j >= last_index; j--) { this._header.removeChild(this._header.children[j]); } - - this._tab_panel_state = new_tab_panel; - }; - - private createTab = (tab_panel: TabLayout, index: number): HTMLDivElement => { - const selected = tab_panel.selected || 0; - const tab = document.createElement("div"); - this._tab_to_index_map.set(tab, index); - tab.textContent = tab_panel.child[index] || ""; - if (index === selected) { - tab.setAttribute("part", "tab active-tab"); - } else { - tab.setAttribute("part", "tab"); - tab.addEventListener("pointerdown", (_) => - this.onTabClick(tab_panel, index), - ); - } - - return tab; - }; - - private onTabClick = (tab_panel: TabLayout, index: number) => { - const new_layout = this._layout.save(); - const new_tab_panel = this._layout.getPanel( - tab_panel.child[index], - new_layout, - ); - - if (new_tab_panel) { - new_tab_panel.selected = index; - this._layout.restore(new_layout); - } }; } diff --git a/src/regular-layout-tab.ts b/src/regular-layout-tab.ts new file mode 100644 index 0000000..4214d49 --- /dev/null +++ b/src/regular-layout-tab.ts @@ -0,0 +1,103 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ +// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import type { TabLayout } from "./layout/types.ts"; +import type { RegularLayout } from "./regular-layout.ts"; + +/** + * A custom HTML element representing an individual tab in a tab panel. + * + * This element manages the visual representation and interactions for a single tab, + * including selection state, close functionality, and content display. + */ +export class RegularLayoutTab extends HTMLElement { + private _layout?: RegularLayout; + private _tab_panel?: TabLayout; + private _index?: number; + + /** + * Populates or updates the tab with layout information. + * + * This method initializes the tab's content and event listeners on first call, + * and efficiently updates only the changed properties on subsequent calls. + * + * @param layout - The parent RegularLayout instance managing this tab. + * @param tab_panel - The tab panel layout containing this tab. + * @param index - The index of this tab within the tab panel. + */ + populate = (layout: RegularLayout, tab_panel: TabLayout, index: number) => { + if (this._tab_panel) { + const tab_changed = + (index === tab_panel.selected) !== + (index === this._tab_panel?.selected); + + const index_changed = + tab_changed || this._tab_panel?.child[index] !== tab_panel.child[index]; + + if (index_changed) { + const selected = tab_panel.selected === index; + const slot = tab_panel.child[index]; + this.children[0].textContent = slot; + + if (selected) { + this.children[1].part.add("active-close"); + this.part.add("active-tab"); + } else { + this.children[1].part.remove("active-close"); + this.part.remove("active-tab"); + } + } + } else { + const slot = tab_panel.child[index]; + const selected = tab_panel.selected === index; + const parts = selected ? "active-close close" : "close"; + this.innerHTML = `
`; + if (selected) { + this.part.add("tab", "active-tab"); + } else { + this.part.add("tab"); + } + + this.addEventListener("pointerdown", this.onTabClick); + this.children[0].textContent = slot; + this.children[1].addEventListener("pointerdown", this.onTabClose); + } + + this._tab_panel = tab_panel; + this._layout = layout; + this._index = index; + }; + + private onTabClose = (_: Event) => { + if (this._tab_panel !== undefined && this._index !== undefined) { + this._layout?.removePanel(this._tab_panel.child[this._index]); + } + }; + + private onTabClick = (_: PointerEvent) => { + if ( + this._tab_panel !== undefined && + this._index !== undefined && + this._index !== this._tab_panel.selected + ) { + const new_layout = this._layout?.save(); + const new_tab_panel = this._layout?.getPanel( + this._tab_panel.child[this._index], + new_layout, + ); + + if (new_tab_panel && new_layout) { + new_tab_panel.selected = this._index; + this._layout?.restore(new_layout); + } + } + }; +} diff --git a/src/regular-layout.ts b/src/regular-layout.ts index 38e104b..a9ae89c 100644 --- a/src/regular-layout.ts +++ b/src/regular-layout.ts @@ -16,23 +16,28 @@ * @packageDocumentation */ -import { EMPTY_PANEL, iter_panel_children } from "./common/layout_config.ts"; -import { create_css_grid_layout } from "./common/generate_grid.ts"; +import { EMPTY_PANEL } from "./layout/types.ts"; +import { create_css_grid_layout } from "./layout/generate_grid.ts"; import type { LayoutPath, Layout, LayoutDivider, TabLayout, OverlayMode, -} from "./common/layout_config.ts"; -import { calculate_intersection } from "./common/calculate_intersect.ts"; -import { remove_child } from "./common/remove_child.ts"; -import { insert_child } from "./common/insert_child.ts"; -import { redistribute_panel_sizes } from "./common/redistribute_panel_sizes.ts"; -import { updateOverlaySheet } from "./common/generate_overlay.ts"; -import { calculate_edge } from "./common/calculate_edge.ts"; -import { flatten } from "./common/flatten.ts"; -import { OVERLAY_CLASSNAME, OVERLAY_DEFAULT } from "./common/constants.ts"; + Orientation, +} from "./layout/types.ts"; +import { calculate_intersection } from "./layout/calculate_intersect.ts"; +import { remove_child } from "./layout/remove_child.ts"; +import { insert_child } from "./layout/insert_child.ts"; +import { redistribute_panel_sizes } from "./layout/redistribute_panel_sizes.ts"; +import { updateOverlaySheet } from "./layout/generate_overlay.ts"; +import { calculate_edge } from "./layout/calculate_edge.ts"; +import { flatten } from "./layout/flatten.ts"; +import { + CUSTOM_EVENT_NAME_PREFIX, + OVERLAY_CLASSNAME, + OVERLAY_DEFAULT, +} from "./layout/constants.ts"; /** * A Web Component that provides a resizable panel layout system. @@ -43,8 +48,8 @@ import { OVERLAY_CLASSNAME, OVERLAY_DEFAULT } from "./common/constants.ts"; * @example * ```html * - *
Sidebar content
- *
Main content
+ *
Sidebar content
+ *
Main content
*
* ``` * @@ -71,19 +76,34 @@ export class RegularLayout extends HTMLElement { private _shadowRoot: ShadowRoot; private _panel: Layout; private _stylesheet: CSSStyleSheet; - private _dragPath?: [LayoutDivider, number, number]; - private _slots: Map; - private _unslotted_slot: HTMLSlotElement; + private _cursor_stylesheet: CSSStyleSheet; + private _drag_target?: [LayoutDivider, number, number]; + private _cursor_override: boolean; + private _dimensions?: { box: DOMRect; style: CSSStyleDeclaration }; constructor() { super(); this._panel = structuredClone(EMPTY_PANEL); - this._stylesheet = new CSSStyleSheet(); - this._unslotted_slot = document.createElement("slot"); + + // Why does this implementation use a `` at all? We must use + // `` and the Shadow DOM to scope the grid CSS rules to each + // instance of `` (without e.g. giving them unique + // `"id"` and injecting into `document,head`), and we can only select + // `::slotted` light DOM children from `adoptedStyleSheets` on the + // `ShadowRoot`. + + // In addition, this model uses a single un-named `` to host all + // light-DOM children, and the child's `"name"` attribute to identify + // its position in the `Layout`. Alternatively, using named this._shadowRoot = this.attachShadow({ mode: "open" }); - this._shadowRoot.adoptedStyleSheets = [this._stylesheet]; - this._shadowRoot.appendChild(this._unslotted_slot); - this._slots = new Map(); + this._shadowRoot.innerHTML = ``; + this._stylesheet = new CSSStyleSheet(); + this._cursor_stylesheet = new CSSStyleSheet(); + this._cursor_override = false; + this._shadowRoot.adoptedStyleSheets = [ + this._stylesheet, + this._cursor_stylesheet, + ]; } connectedCallback() { @@ -98,6 +118,33 @@ export class RegularLayout extends HTMLElement { this.removeEventListener("pointermove", this.onPointerMove); } + /** + * Determines which panel is at a given screen coordinate. + * + * @param column - X coordinate in screen pixels. + * @param row - Y coordinate in screen pixels. + * @returns Panel information if a panel is at that position, null otherwise. + */ + calculateIntersect = ( + x: number, + y: number, + check_dividers: boolean = false, + ): LayoutPath | null => { + const [col, row, box] = this.relativeCoordinates(x, y, false); + const panel = calculate_intersection( + col, + row, + this._panel, + check_dividers ? box : null, + ); + + if (panel?.type === "layout-path") { + return { ...panel, layout: this.save() }; + } + + return null; + }; + /** * Sets the visual overlay state during drag-and-drop operations. * Displays a preview of where a panel would be placed at the given coordinates. @@ -119,35 +166,29 @@ export class RegularLayout extends HTMLElement { className: string = OVERLAY_CLASSNAME, mode: OverlayMode = OVERLAY_DEFAULT, ) => { - let panel = this._panel; - if (mode === "absolute") { - panel = remove_child(panel, slot); - this.updateSlots(panel, slot); - this._slots.get(slot)?.assignedElements()[0]?.classList.add(className); - } + const panel = remove_child(this._panel, slot); + Array.from(this.children) + .find((x) => x.getAttribute("name") === slot) + ?.classList.add(className); - const [col, row, box] = this.relativeCoordinates(x, y); - let drop_target = calculate_intersection(col, row, panel, false); + const [col, row, box, style] = this.relativeCoordinates(x, y, false); + let drop_target = calculate_intersection(col, row, panel); if (drop_target) { - drop_target = calculate_edge(col, row, panel, slot, drop_target); - if (mode === "grid") { - const path: [string, string] = [slot, drop_target.slot]; - const css = create_css_grid_layout(this._panel, false, path); - this._stylesheet.replaceSync(css); - } else if (mode === "absolute") { - const grid_css = create_css_grid_layout(panel); - const overlay_css = updateOverlaySheet(slot, { ...drop_target, box }); - this._stylesheet.replaceSync([grid_css, overlay_css].join("\n")); - } - } else { - const css = `${create_css_grid_layout(panel)}}`; - this._stylesheet.replaceSync(css); + drop_target = calculate_edge(col, row, panel, slot, drop_target, box); } - const event = new CustomEvent("regular-layout-before-update", { - detail: panel, - }); + if (mode === "grid" && drop_target) { + const path: [string, string] = [slot, drop_target?.slot]; + const css = create_css_grid_layout(panel, false, path); + this._stylesheet.replaceSync(css); + } else if (mode === "absolute") { + const grid_css = create_css_grid_layout(panel); + const overlay_css = updateOverlaySheet(slot, box, style, drop_target); + this._stylesheet.replaceSync([grid_css, overlay_css].join("\n")); + } + const event_name = `${CUSTOM_EVENT_NAME_PREFIX}-before-update`; + const event = new CustomEvent(event_name, { detail: panel }); this.dispatchEvent(event); }; @@ -166,21 +207,17 @@ export class RegularLayout extends HTMLElement { clearOverlayState = ( x: number, y: number, - drag_target: LayoutPath, + drag_target: LayoutPath, className: string = OVERLAY_CLASSNAME, - mode: OverlayMode = OVERLAY_DEFAULT, ) => { let panel = this._panel; - if (mode === "absolute") { - panel = remove_child(panel, drag_target.slot); - this._slots - .get(drag_target.slot) - ?.assignedElements()[0] - ?.classList.remove(className); - } + panel = remove_child(panel, drag_target.slot); + Array.from(this.children) + .find((x) => x.getAttribute("name") === drag_target.slot) + ?.classList.remove(className); - const [col, row, _] = this.relativeCoordinates(x, y); - let drop_target = calculate_intersection(col, row, panel, false); + const [col, row, box] = this.relativeCoordinates(x, y, false); + let drop_target = calculate_intersection(col, row, panel); if (drop_target) { drop_target = calculate_edge( col, @@ -188,19 +225,21 @@ export class RegularLayout extends HTMLElement { panel, drag_target.slot, drop_target, + box, ); } const { path, orientation } = drop_target ? drop_target : drag_target; - this.restore( - insert_child( - panel, - drag_target.slot, - path, - orientation, - !drop_target?.is_edge, - ), - ); + const new_layout = drop_target + ? insert_child( + panel, + drag_target.slot, + path, + drop_target?.is_edge ? orientation : undefined, + ) + : drag_target.layout; + + this.restore(new_layout); }; /** @@ -208,9 +247,25 @@ export class RegularLayout extends HTMLElement { * * @param name - Unique identifier for the new panel. * @param path - Index path defining where to insert. + * @param split - Force a split in the layout at the end of `path` + * regardless if there is a leaf at this position or not. Optionally, + *. `split` may be your preferred `Orientation`, which will be used by + * the new `SplitPanel` _if_ there is an option of orientation (e.g. if + * the layout had no pre-existing `SplitPanel`) */ - insertPanel = (name: string, path: number[] = []) => { - this.restore(insert_child(this._panel, name, path)); + insertPanel = ( + name: string, + path: number[] = [], + split?: boolean | Orientation, + ) => { + let orientation: Orientation | undefined; + if (typeof split === "boolean" && split) { + orientation = "horizontal"; + } else if (typeof split === "string") { + orientation = split; + } + + this.restore(insert_child(this._panel, name, path, orientation)); }; /** @@ -247,27 +302,6 @@ export class RegularLayout extends HTMLElement { return null; }; - /** - * Determines which panel is at a given screen coordinate. - * - * @param column - X coordinate in screen pixels. - * @param row - Y coordinate in screen pixels. - * @returns Panel information if a panel is at that position, null otherwise. - */ - calculateIntersect = ( - x: number, - y: number, - check_dividers: boolean = false, - ): LayoutPath | null => { - const [col, row, box] = this.relativeCoordinates(x, y); - const panel = calculate_intersection(col, row, this._panel, check_dividers); - if (panel?.type === "layout-path") { - return { ...panel, box }; - } - - return null; - }; - /** * Clears the entire layout, unslotting all panels. */ @@ -291,11 +325,8 @@ export class RegularLayout extends HTMLElement { this._panel = !_is_flattened ? flatten(layout) : layout; const css = create_css_grid_layout(this._panel); this._stylesheet.replaceSync(css); - this.updateSlots(this._panel); - const event = new CustomEvent("regular-layout-update", { - detail: this._panel, - }); - + const event_name = `${CUSTOM_EVENT_NAME_PREFIX}-update`; + const event = new CustomEvent(event_name, { detail: this._panel }); this.dispatchEvent(event); }; @@ -331,44 +362,41 @@ export class RegularLayout extends HTMLElement { relativeCoordinates = ( clientX: number, clientY: number, - ): [number, number, DOMRect] => { - const box = this.getBoundingClientRect(); - const col = (clientX - box.left) / (box.right - box.left); - const row = (clientY - box.top) / (box.bottom - box.top); - return [col, row, box]; - }; - - private updateSlots = (layout: Layout, overlay?: string) => { - const old = new Set(this._slots.keys()); - if (overlay) { - old.delete(overlay); + recalculate_bounds: boolean = true, + ): [number, number, DOMRect, CSSStyleDeclaration] => { + if (recalculate_bounds || !this._dimensions) { + this._dimensions = { + box: this.getBoundingClientRect(), + style: getComputedStyle(this), + }; } - for (const name of iter_panel_children(layout)) { - old.delete(name); - if (!this._slots.has(name)) { - const slot = document.createElement("slot"); - slot.setAttribute("name", name); - this._shadowRoot.appendChild(slot); - this._slots.set(name, slot); - } - } - - for (const key of old) { - const child = this._slots.get(key); - if (child) { - this._shadowRoot.removeChild(child); - this._slots.delete(key); - } - } + const box = this._dimensions.box; + const style = this._dimensions.style; + const col = + (clientX - box.left - parseFloat(style.paddingLeft)) / + (box.width - + parseFloat(style.paddingLeft) - + parseFloat(style.paddingRight)); + const row = + (clientY - box.top - parseFloat(style.paddingTop)) / + (box.height - + parseFloat(style.paddingTop) - + parseFloat(style.paddingBottom)); + + return [col, row, box, style]; }; private onPointerDown = (event: PointerEvent) => { if (event.target === this) { - const [col, row] = this.relativeCoordinates(event.clientX, event.clientY); - const hit = calculate_intersection(col, row, this._panel); + const [col, row, box] = this.relativeCoordinates( + event.clientX, + event.clientY, + ); + + const hit = calculate_intersection(col, row, this._panel, box); if (hit && hit.type !== "layout-path") { - this._dragPath = [hit, col, row]; + this._drag_target = [hit, col, row]; this.setPointerCapture(event.pointerId); event.preventDefault(); } @@ -376,31 +404,52 @@ export class RegularLayout extends HTMLElement { }; private onPointerMove = (event: PointerEvent) => { - if (this._dragPath) { - const [col, row] = this.relativeCoordinates(event.clientX, event.clientY); - const old_panel = this._panel; - const [{ path, type }, old_col, old_row] = this._dragPath; + if (this._drag_target) { + const [col, row] = this.relativeCoordinates( + event.clientX, + event.clientY, + false, + ); + + const [{ path, type }, old_col, old_row] = this._drag_target; const offset = type === "horizontal" ? old_col - col : old_row - row; - const panel = redistribute_panel_sizes(old_panel, path, offset); + const panel = redistribute_panel_sizes(this._panel, path, offset); this._stylesheet.replaceSync(create_css_grid_layout(panel)); + } else if (event.target === this) { + const [col, row, box] = this.relativeCoordinates( + event.clientX, + event.clientY, + false, + ); + + const divider = calculate_intersection(col, row, this._panel, box); + if (divider?.type === "vertical") { + this._cursor_stylesheet.replaceSync(":host{cursor:row-resize"); + this._cursor_override = true; + } else if (divider?.type === "horizontal") { + this._cursor_stylesheet.replaceSync(":host{cursor:col-resize"); + this._cursor_override = true; + } + } else if (this._cursor_override) { + this._cursor_override = false; + this._cursor_stylesheet.replaceSync(""); } }; private onPointerUp = (event: PointerEvent) => { - if (this._dragPath) { + if (this._drag_target) { this.releasePointerCapture(event.pointerId); - const [col, row] = this.relativeCoordinates(event.clientX, event.clientY); - const old_panel = this._panel; - const [{ path }, old_col, old_row] = this._dragPath; - if (this._dragPath[0].type === "horizontal") { - const panel = redistribute_panel_sizes(old_panel, path, old_col - col); - this.restore(panel, true); - } else { - const panel = redistribute_panel_sizes(old_panel, path, old_row - row); - this.restore(panel, true); - } + const [col, row] = this.relativeCoordinates( + event.clientX, + event.clientY, + false, + ); - this._dragPath = undefined; + const [{ path, type }, old_col, old_row] = this._drag_target; + const offset = type === "horizontal" ? old_col - col : old_row - row; + const panel = redistribute_panel_sizes(this._panel, path, offset); + this.restore(panel, true); + this._drag_target = undefined; } }; } diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts index 9caeb9c..b565115 100644 --- a/tests/helpers/fixtures.ts +++ b/tests/helpers/fixtures.ts @@ -9,7 +9,7 @@ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ -import type { Layout } from "../../src/common/layout_config.ts"; +import type { Layout } from "../../src/layout/types.ts"; /** * Common layout fixtures for testing diff --git a/tests/helpers/integration.ts b/tests/helpers/integration.ts index 2f84c97..4e77a9c 100644 --- a/tests/helpers/integration.ts +++ b/tests/helpers/integration.ts @@ -10,8 +10,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { Page } from "@playwright/test"; -import { expect } from "@playwright/test"; -import type { Layout } from "../../src/common/layout_config.ts"; +import type { Layout } from "../../src/layout/types.ts"; import type {} from "../../src/extensions.ts"; /** @@ -49,43 +48,6 @@ export async function restoreLayout(page: Page, state: Layout): Promise { }, state); } -/** - * Returns an array of slot names from the layout's shadow DOM. - */ -export async function getSlots(page: Page): Promise { - return await page.evaluate(() => { - const layout = document.querySelector("regular-layout"); - const slotElements = layout?.shadowRoot?.querySelectorAll("slot[name]"); - return Array.from(slotElements || []).map( - (slot) => slot.getAttribute("name") || "", - ); - }); -} - -/** - * Verifies that specific slots exist or don't exist. - */ -export async function expectSlots( - page: Page, - options: { - contains?: string[]; - notContains?: string[]; - }, -): Promise { - const slots = await getSlots(page); - if (options.contains) { - for (const slot of options.contains) { - expect(slots).toContain(slot); - } - } - - if (options.notContains) { - for (const slot of options.notContains) { - expect(slots).not.toContain(slot); - } - } -} - /** * Performs a mouse drag operation from one point to another. */ @@ -155,79 +117,6 @@ export async function setOverlayState( ); } -/** - * Calls clearOverlayState with the given coordinates and options. - */ -export async function clearOverlayState( - page: Page, - x: number, - y: number, - slot: string, - className?: string, - mode?: "grid" | "absolute", -): Promise { - await page.evaluate( - ({ x, y, slot, className, mode }) => { - const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); - if (layoutPath) { - layout?.clearOverlayState( - x, - y, - { ...layoutPath, slot }, - className, - mode, - ); - } - }, - { x, y, slot, className, mode }, - ); -} - -/** - * Gets the computed CSS for a slot element. - */ -export async function getSlotCSS( - page: Page, - slotName: string, -): Promise<{ - gridArea?: string; - display?: string; -}> { - return await page.evaluate((name) => { - const layout = document.querySelector("regular-layout"); - const slot = layout?.shadowRoot?.querySelector(`slot[name="${name}"]`); - if (!slot) return {}; - - const computedStyle = window.getComputedStyle(slot); - return { - gridArea: computedStyle.gridArea, - display: computedStyle.display, - }; - }, slotName); -} - -/** - * Checks if an element has a specific CSS class. - */ -export async function hasClass( - page: Page, - slotName: string, - className: string, -): Promise { - return await page.evaluate( - ({ name, className }) => { - const layout = document.querySelector("regular-layout"); - const slot = layout?.shadowRoot?.querySelector( - `slot[name="${name}"]`, - ) as HTMLSlotElement | null; - const element = slot?.assignedElements()[0]; - return element?.classList.contains(className) || false; - }, - { name: slotName, className }, - ); -} - /** * Gets the layout bounds for testing overlay coordinates. */ diff --git a/tests/integration/adopted-stylesheets.spec.ts b/tests/integration/adopted-stylesheets.spec.ts new file mode 100644 index 0000000..870c6d9 --- /dev/null +++ b/tests/integration/adopted-stylesheets.spec.ts @@ -0,0 +1,312 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ +// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { setupLayout, restoreLayout } from "../helpers/integration.ts"; +import { LAYOUTS } from "../helpers/fixtures.ts"; + +/** + * Helper function to get the CSS content from adoptedStyleSheets + */ +async function getAdoptedStyleSheetCSS( + page: Page, + index: number = 0, +): Promise { + return await page.evaluate((idx: number) => { + const layout = document.querySelector("regular-layout"); + if (!layout) return ""; + const shadowRoot = (layout as unknown as { shadowRoot: ShadowRoot }) + .shadowRoot; + if (!shadowRoot?.adoptedStyleSheets?.[idx]) return ""; + const rules = Array.from(shadowRoot.adoptedStyleSheets[idx].cssRules); + return rules.map((rule: CSSRule) => rule.cssText).join("\n"); + }, index); +} + +/** + * Helper to normalize CSS by removing whitespace variations + */ +function normalizeCSS(css: string): string { + return css + .replace(/\s+/g, " ") + .replace(/\s*{\s*/g, "{") + .replace(/\s*}\s*/g, "}") + .replace(/\s*:\s*/g, ":") + .replace(/\s*;\s*/g, ";") + .trim(); +} + +test("should generate correct CSS for SINGLE_AAA layout", async ({ page }) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS(":host::slotted(*){display:none;}"), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:100%;grid-template-columns:100%;}", + ), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + ); +}); + +test("should generate correct CSS for SINGLE_TABS layout", async ({ page }) => { + await setupLayout(page, LAYOUTS.SINGLE_TABS); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + ); + + expect(normalizeCSS(css)).not.toContain('name="BBB"'); + expect(normalizeCSS(css)).not.toContain('name="CCC"'); +}); + +test("should generate correct CSS for TWO_HORIZONTAL layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:100fr;grid-template-columns:30fr 70fr;}", + ), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="BBB"]){display:flex;grid-area:1 / 2;}'), + ); +}); + +test("should generate correct CSS for TWO_VERTICAL layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_VERTICAL); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:50fr 50fr;grid-template-columns:100fr;}", + ), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="BBB"]){display:flex;grid-area:2 / 1;}'), + ); +}); + +test("should generate correct CSS for THREE_HORIZONTAL layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.THREE_HORIZONTAL); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:100fr;grid-template-columns:30fr 30fr 40fr;}", + ), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="BBB"]){display:flex;grid-area:1 / 2;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="CCC"]){display:flex;grid-area:1 / 3;}'), + ); +}); + +test("should generate correct CSS for NESTED_BASIC layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.NESTED_BASIC); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:30fr 70fr;grid-template-columns:60fr 40fr;}", + ), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="BBB"]){display:flex;grid-area:2 / 1;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ':host::slotted([name="CCC"]){display:flex;grid-area:1 / 2 / 3;}', + ), + ); +}); + +test("should generate correct CSS for NESTED_VERTICAL_OUTER layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.NESTED_VERTICAL_OUTER); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:60fr 40fr;grid-template-columns:30fr 70fr;}", + ), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="BBB"]){display:flex;grid-area:1 / 2;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ':host::slotted([name="CCC"]){display:flex;grid-area:2 / 1 / auto / 3;}', + ), + ); +}); + +test("should generate correct CSS for DEEPLY_NESTED layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.DEEPLY_NESTED); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain("grid-template-columns:60fr 40fr"); + expect(css).toContain("grid-template-rows"); + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).toContain('name="BBB"'); + expect(normalizeCSS(css)).toContain('name="CCC"'); + expect(normalizeCSS(css)).toContain('name="DDD"'); +}); + +test("should generate correct CSS for THREE_HORIZONTAL_CUSTOM sizes", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.THREE_HORIZONTAL_CUSTOM); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:100fr;grid-template-columns:20fr 30fr 50fr;}", + ), + ); + + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).toContain('name="BBB"'); + expect(normalizeCSS(css)).toContain('name="CCC"'); +}); + +test("should generate correct CSS for THREE_VERTICAL_CDE layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.THREE_VERTICAL_CDE); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain( + normalizeCSS( + ":host{display:grid;grid-template-rows:30fr 30fr 40fr;grid-template-columns:100fr;}", + ), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="CCC"]){display:flex;grid-area:1 / 1;}'), + ); + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="DDD"]){display:flex;grid-area:2 / 1;}'), + ); + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="EEE"]){display:flex;grid-area:3 / 1;}'), + ); +}); + +test("should generate correct CSS for COMPLEX_FOUR_CHILDREN layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.COMPLEX_FOUR_CHILDREN); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).toContain('name="BBB"'); + expect(normalizeCSS(css)).toContain('name="CCC"'); + expect(normalizeCSS(css)).toContain('name="DDD"'); + expect(normalizeCSS(css)).toContain('name="EEE"'); + expect(normalizeCSS(css)).toContain('name="FFF"'); + const panels = ["AAA", "BBB", "CCC", "DDD", "EEE", "FFF"]; + for (const panel of panels) { + const regex = new RegExp( + `::slotted\\(\\[name="${panel}"\\]\\)\\s*{[^}]*display:\\s*flex`, + ); + expect(css).toMatch(regex); + } +}); + +test("should update CSS when restoring different layouts", async ({ page }) => { + await setupLayout(page, LAYOUTS.SINGLE_AAA); + let css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).not.toContain('name="BBB"'); + + await restoreLayout(page, LAYOUTS.TWO_HORIZONTAL); + css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).toContain('name="BBB"'); + expect(normalizeCSS(css)).toContain("grid-template-columns:30fr 70fr"); + + await restoreLayout(page, LAYOUTS.NESTED_BASIC); + css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).toContain('name="BBB"'); + expect(normalizeCSS(css)).toContain('name="CCC"'); + expect(normalizeCSS(css)).toContain("grid-template-columns:60fr 40fr"); + expect(normalizeCSS(css)).toContain("grid-template-rows:30fr 70fr"); +}); + +test("should generate correct CSS for TWO_HORIZONTAL_WITH_TABS", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_WITH_TABS); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).toContain('name="CCC"'); + expect(normalizeCSS(css)).not.toContain('name="BBB"'); + expect(normalizeCSS(css)).toContain("grid-template-columns:50fr 50fr"); +}); + +test("should generate correct CSS for NESTED_ALIGNED layout", async ({ + page, +}) => { + await setupLayout(page, LAYOUTS.NESTED_ALIGNED); + const css = await getAdoptedStyleSheetCSS(page); + expect(normalizeCSS(css)).toContain('name="AAA"'); + expect(normalizeCSS(css)).toContain('name="BBB"'); + expect(normalizeCSS(css)).toContain('name="DDD"'); + expect(normalizeCSS(css)).toContain('name="FFF"'); + expect(css).toMatch(/grid-area/); +}); + +test("should handle empty layout", async ({ page }) => { + await setupLayout(page); + const css = await getAdoptedStyleSheetCSS(page); + expect(css.length).toBeGreaterThan(0); +}); diff --git a/tests/integration/insert-panel.spec.ts b/tests/integration/insert-panel.spec.ts index 669ecfe..c4b2842 100644 --- a/tests/integration/insert-panel.spec.ts +++ b/tests/integration/insert-panel.spec.ts @@ -14,12 +14,13 @@ import type { Layout } from "../../dist/index.js"; import { setupLayout, saveLayout, - expectSlots, insertPanel, } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; -test("should insert a single panel into an empty layout", async ({ page }) => { +test("should insert a single panel into a single panel layout", async ({ + page, +}) => { await setupLayout(page, LAYOUTS.SINGLE_AAA); const currentState = await saveLayout(page); expect(currentState).toStrictEqual({ @@ -35,8 +36,6 @@ test("should insert a single panel into an empty layout", async ({ page }) => { child: ["BBB", "AAA"], selected: 0, }); - - await expectSlots(page, { contains: ["BBB"] }); }); test("should insert panel at specific path in split panel", async ({ @@ -111,7 +110,7 @@ test("should insert panel into nested split panel", async ({ page }) => { selected: 0, }, ], - sizes: [0.3333333333333333, 0.3333333333333333, 0.3333333333333333], + sizes: [0.19999999999999998, 0.4666666666666666, 0.3333333333333333], }, { type: "child-panel", @@ -136,7 +135,7 @@ test("should split existing panel when inserting at deeper path", async ({ await page.evaluate(() => { const layout = document.querySelector("regular-layout"); - layout?.insertPanel("CCC", [1, 1]); + layout?.insertPanel("CCC", [1, 1], "vertical"); }); const afterInsert = await page.evaluate(() => { diff --git a/tests/integration/overlay-absolute.spec.ts b/tests/integration/overlay-absolute.spec.ts index 3ed251e..4503c7f 100644 --- a/tests/integration/overlay-absolute.spec.ts +++ b/tests/integration/overlay-absolute.spec.ts @@ -10,11 +10,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { expect, test } from "@playwright/test"; -import { - setupLayout, - getLayoutBounds, - hasClass, -} from "../helpers/integration.ts"; +import { setupLayout, getLayoutBounds } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; test("should apply overlay class to dragged panel in absolute mode", async ({ @@ -38,8 +34,8 @@ test("should apply overlay class to dragged panel in absolute mode", async ({ ); // Verify AAA panel has overlay class - const hasOverlayClass = await hasClass(page, "AAA", "overlay"); - expect(hasOverlayClass).toBe(true); + const panel = await page.locator("[name=AAA]"); + expect(panel).toHaveClass("overlay"); }); test("should dispatch regular-layout-update event in absolute mode", async ({ @@ -99,9 +95,9 @@ test("should handle custom className in absolute mode", async ({ page }) => { { x, y }, ); - const hasCustomClass = await hasClass(page, "AAA", "custom-drag-class"); - expect(hasCustomClass).toBe(true); + const panel = await page.locator("[name=AAA]"); + expect(panel).toHaveClass("custom-drag-class"); - const hasDefaultClass = await hasClass(page, "AAA", "overlay"); - expect(hasDefaultClass).toBe(false); + const panel2 = await page.locator("[name=AAA]"); + expect(panel2).not.toHaveClass("overlay"); }); diff --git a/tests/integration/overlay-grid.spec.ts b/tests/integration/overlay-grid.spec.ts index 85ce468..748e0e9 100644 --- a/tests/integration/overlay-grid.spec.ts +++ b/tests/integration/overlay-grid.spec.ts @@ -10,35 +10,9 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { expect, test } from "@playwright/test"; -import { - setupLayout, - getLayoutBounds, - hasClass, -} from "../helpers/integration.ts"; +import { setupLayout, getLayoutBounds } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; -test("should not apply overlay class in grid mode", async ({ page }) => { - await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); - const bounds = await getLayoutBounds(page); - - const x = bounds.x + bounds.width * 0.25; - const y = bounds.y + bounds.height * 0.5; - - await page.evaluate( - ({ x, y }) => { - const layout = document.querySelector("regular-layout"); - const layoutPath = layout?.calculateIntersect(x, y); - if (layoutPath) { - layout?.setOverlayState(x, y, layoutPath, "overlay", "grid"); - } - }, - { x, y }, - ); - - const hasOverlayClass = await hasClass(page, "AAA", "overlay"); - expect(hasOverlayClass).toBe(false); -}); - test("should update CSS with grid preview in grid mode", async ({ page }) => { await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); const bounds = await getLayoutBounds(page); diff --git a/tests/integration/remove-panel.spec.ts b/tests/integration/remove-panel.spec.ts index 52cce43..081bb6e 100644 --- a/tests/integration/remove-panel.spec.ts +++ b/tests/integration/remove-panel.spec.ts @@ -13,7 +13,6 @@ import { expect, test } from "@playwright/test"; import { setupLayout, saveLayout, - expectSlots, removePanel, } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; @@ -29,11 +28,6 @@ test.describe("removePanel", () => { child: ["AAA"], selected: 0, }); - - await expectSlots(page, { - notContains: ["BBB"], - contains: ["AAA"], - }); }); test("should remove panel from 3-panel layout", async ({ page }) => { diff --git a/tests/integration/resize.spec.ts b/tests/integration/resize.spec.ts index e90437d..939a8f8 100644 --- a/tests/integration/resize.spec.ts +++ b/tests/integration/resize.spec.ts @@ -27,7 +27,9 @@ test("should resize panels by dragging dividers and preserve state with save/res expect(initialState).toHaveProperty("sizes"); const layoutBox = await page.locator("regular-layout").boundingBox(); expect(layoutBox).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const dividerX = layoutBox!.x + layoutBox!.width * 0.5; + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const dividerY = layoutBox!.y + layoutBox!.height * 0.5; const dragDistance = -100; await dragMouse(page, dividerX, dividerY, dividerX + dragDistance, dividerY); @@ -50,7 +52,9 @@ test("should resize nested panels by dragging horizontal divider", async ({ const initialState = await saveLayout(page); const layoutBox = await page.locator("regular-layout").boundingBox(); expect(layoutBox).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const nestedDividerX = layoutBox!.x + layoutBox!.width * 0.5; + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const nestedDividerY = layoutBox!.y + layoutBox!.height * 0.25; const dragDistance = 50; await dragMouse( @@ -74,11 +78,15 @@ test("should handle multiple resize operations and save/restore cycles", async ( await setupLayout(page); const layoutBox = await page.locator("regular-layout").boundingBox(); expect(layoutBox).not.toBeNull(); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const divider1X = layoutBox!.x + layoutBox!.width * 0.5; + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const divider1Y = layoutBox!.y + layoutBox!.height * 0.5; await dragMouse(page, divider1X, divider1Y, divider1X - 80, divider1Y); const state1 = await saveLayout(page); + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const divider2X = layoutBox!.x + layoutBox!.width * 0.25; + // biome-ignore lint/style/noNonNullAssertion: playwright expectation const divider2Y = layoutBox!.y + layoutBox!.height * 0.25; await dragMouse(page, divider2X, divider2Y, divider2X, divider2Y + 60); const state2 = await saveLayout(page); diff --git a/tests/integration/save-restore.spec.ts b/tests/integration/save-restore.spec.ts index 2f3ff58..149972e 100644 --- a/tests/integration/save-restore.spec.ts +++ b/tests/integration/save-restore.spec.ts @@ -14,11 +14,10 @@ import { setupLayout, saveLayout, restoreLayout, - getSlots, - expectSlots, insertPanel, } from "../helpers/integration.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; +import type { Layout } from "../../src/layout/types.ts"; test("should save and restore various layout types", async ({ page }) => { // Test single panel @@ -128,22 +127,6 @@ test("should save returns a deep clone, not a reference", async ({ page }) => { }); }); -test("should restore updates shadow DOM slots correctly", async ({ page }) => { - await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); - const initialSlots = await getSlots(page); - expect(initialSlots).toContain("AAA"); - expect(initialSlots).toContain("BBB"); - expect(initialSlots).toHaveLength(2); - await restoreLayout(page, LAYOUTS.THREE_VERTICAL_CDE); - await expectSlots(page, { - notContains: ["AAA", "BBB"], - contains: ["CCC", "DDD", "EEE"], - }); - - const updatedSlots = await getSlots(page); - expect(updatedSlots).toHaveLength(3); -}); - test("should save and restore preserve exact size ratios", async ({ page }) => { await setupLayout(page, LAYOUTS.THREE_HORIZONTAL_PRECISE); const saved = await saveLayout(page); @@ -189,3 +172,199 @@ test("should save and restore handle empty then populated layout", async ({ const restored2 = await saveLayout(page); expect(restored2).toStrictEqual(saved2); }); + +test("should flatten nested split panels with same orientation", async ({ + page, +}) => { + // Test horizontal panels nested within horizontal parent + const nestedHorizontal: Layout = { + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.6, 0.4], + }; + + await setupLayout(page, nestedHorizontal); + const saved = await saveLayout(page); + + // Should be flattened to a single horizontal split with 3 children + expect(saved).toStrictEqual({ + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["AAA"], + selected: 0, + }, + { + type: "child-panel", + child: ["BBB"], + selected: 0, + }, + { + type: "child-panel", + child: ["CCC"], + selected: 0, + }, + ], + sizes: [0.18, 0.42, 0.4], + }); + + // Test vertical panels nested within vertical parent + const nestedVertical: Layout = { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["BBB"], + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.5, 0.5], + }, + ], + sizes: [0.4, 0.6], + }; + + await setupLayout(page, nestedVertical); + const saved2 = await saveLayout(page); + + // Should be flattened to a single vertical split with 3 children + expect(saved2).toStrictEqual({ + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["AAA"], + selected: 0, + }, + { + type: "child-panel", + child: ["BBB"], + selected: 0, + }, + { + type: "child-panel", + child: ["CCC"], + selected: 0, + }, + ], + sizes: [0.4, 0.3, 0.3], + }); +}); + +test("should flatten nested split panels with same orientation, not at the root", async ({ + page, +}) => { + // Test horizontal panels nested within horizontal parent + const nestedHorizontal: Layout = { + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["BBB"], + }, + { + type: "child-panel", + child: ["DDD"], + }, + ], + sizes: [0.3, 0.7], + }, + ], + sizes: [0.3, 0.7], + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.6, 0.4], + }; + + await setupLayout(page, nestedHorizontal); + const saved = await saveLayout(page); + + // Should be flattened to a single horizontal split with 3 children + expect(saved).toStrictEqual({ + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["AAA"], + selected: 0, + }, + { + type: "child-panel", + child: ["BBB"], + selected: 0, + }, + { + type: "child-panel", + child: ["DDD"], + selected: 0, + }, + ], + sizes: [0.3, 0.21, 0.48999999999999994], + }, + { + type: "child-panel", + child: ["CCC"], + selected: 0, + }, + ], + sizes: [0.6, 0.4], + }); +}); diff --git a/tests/integration/tabs.spec.ts b/tests/integration/tabs.spec.ts index a81dde0..89ed102 100644 --- a/tests/integration/tabs.spec.ts +++ b/tests/integration/tabs.spec.ts @@ -24,7 +24,7 @@ test("should switch between tabs by clicking", async ({ page }) => { const getSelectedTab = async (slot: string) => { return await page.evaluate((slot) => { const frame = document.querySelector( - `regular-layout-frame[slot=${slot}]`, + `regular-layout-frame[name=${slot}]`, ); const activeTab = frame?.shadowRoot?.querySelector( '[part~="active-tab"]', @@ -36,7 +36,7 @@ test("should switch between tabs by clicking", async ({ page }) => { const selectedBefore = await getSelectedTab("AAA"); expect(selectedBefore).toBe("AAA"); const frameBounds = await page.evaluate(() => { - const frame = document.querySelector('regular-layout-frame[slot="AAA"]'); + const frame = document.querySelector('regular-layout-frame[name="AAA"]'); const tabs = frame?.shadowRoot?.querySelectorAll('[part~="tab"]'); if (!tabs || tabs.length < 2) return null; const secondTab = tabs[1] as HTMLElement; @@ -76,7 +76,7 @@ test("should move a panel by dragging a selected tab", async ({ page }) => { }, LAYOUTS.TWO_HORIZONTAL_WITH_TABS); const dragCoords = await page.evaluate(() => { - const frame = document.querySelector('regular-layout-frame[slot="AAA"]'); + const frame = document.querySelector('regular-layout-frame[name="AAA"]'); const activeTab = frame?.shadowRoot?.querySelector( '[part~="active-tab"]', ) as HTMLElement; @@ -158,7 +158,7 @@ test("should move a panel by dragging a deselected tab", async ({ page }) => { }); const dragCoords = await page.evaluate(() => { - const frame = document.querySelector('regular-layout-frame[slot="AAA"]'); + const frame = document.querySelector('regular-layout-frame[name="AAA"]'); const tabs = frame?.shadowRoot?.querySelectorAll('[part~="tab"]'); if (!tabs || tabs.length < 2) return null; const inactiveTab = Array.from(tabs).find( diff --git a/tests/unit/calculate_edge.spec.ts b/tests/unit/calculate_edge.spec.ts index 5e0b11c..2e289ac 100644 --- a/tests/unit/calculate_edge.spec.ts +++ b/tests/unit/calculate_edge.spec.ts @@ -11,98 +11,94 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; -import { calculate_edge } from "../../src/common/calculate_edge.ts"; -import { calculate_intersection } from "../../src/common/calculate_intersect.ts"; -import type { LayoutPath, TabLayout } from "../../src/common/layout_config.ts"; +import { calculate_edge } from "../../src/layout/calculate_edge.ts"; +import { calculate_intersection } from "../../src/layout/calculate_intersect.ts"; +import type { LayoutPath, TabLayout } from "../../src/layout/types.ts"; test("cursor in center of panel - no split", () => { - const drop_target = calculate_intersection( - 0.3, - 0.5, - LAYOUTS.NESTED_BASIC, - false, - ); + const drop_target = calculate_intersection(0.3, 0.5, LAYOUTS.NESTED_BASIC); + expect(drop_target).not.toBeNull(); const result = calculate_edge( 0.3, 0.5, LAYOUTS.NESTED_BASIC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); + expect(result.is_edge).toBe(false); expect(result.slot).toBe("BBB"); + expect(result.path).toStrictEqual([0, 1, 0]); }); test("cursor near left edge of vertical split panel", () => { - const drop_target = calculate_intersection( - 0.05, - 0.3, - LAYOUTS.NESTED_BASIC, - false, - ); + const drop_target = calculate_intersection(0.1, 0.5, LAYOUTS.NESTED_BASIC); + expect(drop_target).not.toBeNull(); const result = calculate_edge( - 0.05, - 0.3, + 0.1, + 0.5, LAYOUTS.NESTED_BASIC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); + expect(result.is_edge).toBe(true); - expect(result.slot).toBe("DDD"); + expect(result.slot).toBe("BBB"); + expect(result.path).toStrictEqual([0, 1, 0]); + expect(result.orientation).toBe("horizontal"); }); test("cursor near right edge of vertical split panel", () => { - const drop_target = calculate_intersection( - 0.55, - 0.3, - LAYOUTS.NESTED_BASIC, - false, - ); + const drop_target = calculate_intersection(0.55, 0.5, LAYOUTS.NESTED_BASIC); const result = calculate_edge( 0.55, - 0.3, + 0.5, LAYOUTS.NESTED_BASIC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); + expect(result.is_edge).toBe(true); - expect(result.slot).toBe("DDD"); + expect(result.slot).toBe("BBB"); + expect(result.path).toStrictEqual([0, 1, 1]); + expect(result.orientation).toBe("horizontal"); }); test("cursor near top edge of horizontal split panel", () => { - const drop_target = calculate_intersection( - 0.7, - 0.05, - LAYOUTS.NESTED_BASIC, - false, - ); + const drop_target = calculate_intersection(0.7, 0.05, LAYOUTS.NESTED_BASIC); const result = calculate_edge( 0.7, 0.05, LAYOUTS.NESTED_BASIC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); + expect(result.is_edge).toBe(true); - expect(result.slot).toBe("CCC"); // ??? + expect(result.slot).toBe("CCC"); + expect(result.path).toStrictEqual([1, 0]); + expect(result.orientation).toBe("vertical"); }); test("cursor near bottom edge of horizontal split panel", () => { - const drop_target = calculate_intersection( - 0.7, - 0.95, - LAYOUTS.NESTED_BASIC, - false, - ); + const drop_target = calculate_intersection(0.7, 0.95, LAYOUTS.NESTED_BASIC); const result = calculate_edge( 0.7, 0.95, LAYOUTS.NESTED_BASIC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); + expect(result.is_edge).toBe(true); - expect(result.slot).toBe("CCC"); // ??? + expect(result.slot).toBe("CCC"); + expect(result.path).toStrictEqual([1, 1]); + expect(result.orientation).toBe("vertical"); }); test("cursor near left edge but with horizontal orientation", () => { @@ -110,7 +106,6 @@ test("cursor near left edge but with horizontal orientation", () => { 0.05, 0.5, LAYOUTS.SINGLE_SPLIT_HORIZONTAL, - false, ); const result = calculate_edge( @@ -118,11 +113,14 @@ test("cursor near left edge but with horizontal orientation", () => { 0.5, LAYOUTS.SINGLE_SPLIT_HORIZONTAL, "BBB", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); expect(result.is_edge).toBe(true); - expect(result.slot).toBe("BBB"); + expect(result.slot).toBe("AAA"); + expect(result.path).toStrictEqual([0]); + expect(result.orientation).toBe("horizontal"); }); test("cursor near right edge but with horizontal orientation", () => { @@ -130,7 +128,6 @@ test("cursor near right edge but with horizontal orientation", () => { 0.95, 0.5, LAYOUTS.SINGLE_SPLIT_HORIZONTAL, - false, ); const result = calculate_edge( @@ -138,11 +135,12 @@ test("cursor near right edge but with horizontal orientation", () => { 0.5, LAYOUTS.SINGLE_SPLIT_HORIZONTAL, "BBB", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); expect(result.is_edge).toBe(true); - expect(result.slot).toBe("BBB"); + expect(result.slot).toBe("AAA"); }); test("cursor near top edge but with vertical orientation", () => { @@ -150,7 +148,6 @@ test("cursor near top edge but with vertical orientation", () => { 0.5, 0.05, LAYOUTS.SINGLE_SPLIT_VERTICAL, - false, ); const result = calculate_edge( @@ -158,11 +155,12 @@ test("cursor near top edge but with vertical orientation", () => { 0.05, LAYOUTS.SINGLE_SPLIT_VERTICAL, "BBB", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); expect(result.is_edge).toBe(true); - expect(result.slot).toBe("BBB"); + expect(result.slot).toBe("AAA"); }); test("cursor near bottom edge but with vertical orientation", () => { @@ -170,7 +168,6 @@ test("cursor near bottom edge but with vertical orientation", () => { 0.5, 0.95, LAYOUTS.SINGLE_SPLIT_VERTICAL, - false, ); const result = calculate_edge( @@ -178,28 +175,30 @@ test("cursor near bottom edge but with vertical orientation", () => { 0.95, LAYOUTS.SINGLE_SPLIT_VERTICAL, "BBB", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); expect(result.is_edge).toBe(true); - expect(result.slot).toBe("BBB"); + expect(result.slot).toBe("AAA"); }); test("integrated top edge", () => { const col = 0.51; - const row = 0.002; - let drop_target = calculate_intersection(col, row, LAYOUTS.SINGLE_AAA, false); + const row = 0.15; + let drop_target = calculate_intersection(col, row, LAYOUTS.SINGLE_AAA); if (drop_target) { drop_target = calculate_edge( col, row, LAYOUTS.SINGLE_AAA, "BBB", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); } - expect(drop_target.view_window).toStrictEqual({ + expect(drop_target?.view_window).toStrictEqual({ col_end: 1, col_start: 0, row_end: 0.5, @@ -208,9 +207,9 @@ test("integrated top edge", () => { }); test("integrated right edge", () => { - const col = 0.998; + const col = 0.85; const row = 0.53; - let drop_target = calculate_intersection(col, row, LAYOUTS.SINGLE_AAA, false); + let drop_target = calculate_intersection(col, row, LAYOUTS.SINGLE_AAA); if (drop_target) { drop_target = calculate_edge( col, @@ -221,27 +220,61 @@ test("integrated right edge", () => { ); } - expect(drop_target).toStrictEqual({ - box: undefined, - column: 0.998, - column_offset: 0.996, - is_edge: true, - orientation: "horizontal", - panel: { - child: ["BBB"], - type: "child-panel", - }, - path: [1], - row: 0.53, - row_offset: 0.53, - slot: "BBB", - type: "layout-path", - view_window: { - col_end: 1, - col_start: 0.5, - row_end: 1, - row_start: 0, - }, + expect(drop_target?.is_edge).toBe(true); + expect(drop_target?.path).toStrictEqual([1]); + expect(drop_target?.view_window).toStrictEqual({ + col_end: 1, + col_start: 0.5, + row_end: 1, + row_start: 0, + }); +}); + +test("integrated far right edge", () => { + const col = 0.996; + const row = 0.53; + let drop_target = calculate_intersection(col, row, LAYOUTS.SINGLE_AAA); + if (drop_target) { + drop_target = calculate_edge( + col, + row, + LAYOUTS.SINGLE_AAA, + "BBB", + drop_target, + ); + } + + expect(drop_target?.is_edge).toBe(true); + expect(drop_target?.path).toStrictEqual([1]); + expect(drop_target?.view_window).toStrictEqual({ + col_end: 1, + col_start: 0.5, + row_end: 1, + row_start: 0, + }); +}); + +test("integrated far right edge 2", () => { + const col = 0.996; + const row = 0.53; + let drop_target = calculate_intersection(col, row, LAYOUTS.NESTED_BASIC); + if (drop_target) { + drop_target = calculate_edge( + col, + row, + LAYOUTS.SINGLE_AAA, + "BBB", + drop_target, + ); + } + + expect(drop_target?.is_edge).toBe(true); + expect(drop_target?.path).toStrictEqual([2]); + expect(drop_target?.view_window).toStrictEqual({ + col_end: 1, + col_start: 0.5, + row_end: 1, + row_start: 0, }); }); @@ -250,8 +283,8 @@ test("cursor in top-left corner prioritizes row offset", () => { const drop_target: LayoutPath = { type: "layout-path", slot: "AAA", - panel: singlePanel as TabLayout, path: [], + layout: undefined, view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.1, row: 0.05, @@ -259,11 +292,17 @@ test("cursor in top-left corner prioritizes row offset", () => { row_offset: 0.05, orientation: "horizontal", is_edge: false, - box: undefined, }; const result = calculate_edge(0.1, 0.05, singlePanel, "BBB", drop_target); expect(result.is_edge).toBe(true); + expect(result.path).toStrictEqual([0]); + expect(result.view_window).toStrictEqual({ + col_end: 1, + col_start: 0, + row_end: 0.5, + row_start: 0, + }); }); test("cursor in bottom-right corner prioritizes row offset", () => { @@ -271,7 +310,7 @@ test("cursor in bottom-right corner prioritizes row offset", () => { const drop_target: LayoutPath = { type: "layout-path", slot: "AAA", - panel: singlePanel as TabLayout, + layout: undefined, path: [], view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.9, @@ -280,7 +319,6 @@ test("cursor in bottom-right corner prioritizes row offset", () => { row_offset: 0.95, orientation: "horizontal", is_edge: false, - box: undefined, }; const result = calculate_edge(0.9, 0.95, singlePanel, "BBB", drop_target); @@ -293,7 +331,7 @@ test("cursor near edge with offset exactly at tolerance threshold", () => { type: "layout-path", slot: "AAA", path: [], - panel: singlePanel as TabLayout, + layout: undefined, view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.3, row: 0.5, @@ -301,11 +339,11 @@ test("cursor near edge with offset exactly at tolerance threshold", () => { row_offset: 0.5, orientation: "horizontal", is_edge: false, - box: undefined, }; const result = calculate_edge(0.3, 0.5, singlePanel, "BBB", drop_target); expect(result.is_edge).toBe(false); + expect(result.path).toStrictEqual([0]); }); test("cursor near edge with offset just below tolerance threshold", () => { @@ -313,8 +351,8 @@ test("cursor near edge with offset just below tolerance threshold", () => { const drop_target: LayoutPath = { type: "layout-path", slot: "AAA", - panel: singlePanel as TabLayout, path: [], + layout: undefined, view_window: { row_start: 0, row_end: 1, col_start: 0, col_end: 1 }, column: 0.14, row: 0.5, @@ -322,7 +360,6 @@ test("cursor near edge with offset just below tolerance threshold", () => { row_offset: 0.5, orientation: "horizontal", is_edge: false, - box: undefined, }; const result = calculate_edge(0.14, 0.5, singlePanel, "BBB", drop_target); @@ -330,49 +367,71 @@ test("cursor near edge with offset just below tolerance threshold", () => { }); test("nested panel with vertical orientation at left edge", () => { - const drop_target = calculate_intersection( - 0.02, - 0.3, - LAYOUTS.NESTED_BASIC, - false, - ); + const drop_target = calculate_intersection(0.02, 0.5, LAYOUTS.NESTED_BASIC); const result = calculate_edge( 0.02, - 0.3, + 0.5, LAYOUTS.NESTED_BASIC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); expect(result.is_edge).toBe(true); - expect(result.slot).toBe("DDD"); + expect(result.slot).toBe("BBB"); expect(result.path).toEqual([0, 1, 0]); }); test("nested panel with vertical orientation at right edge", () => { - const drop_target = calculate_intersection( + const drop_target = calculate_intersection(0.58, 0.5, LAYOUTS.NESTED_BASIC); + const result = calculate_edge( 0.58, - 0.3, + 0.5, LAYOUTS.NESTED_BASIC, - false, + "DDD", + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); + expect(result.is_edge).toBe(true); + expect(result.slot).toBe("BBB"); + expect(result.path).toEqual([0, 1, 1]); +}); + +test("nested panel with vertical orientation at right edge2", () => { + const drop_target = calculate_intersection(0.58, 0.5, LAYOUTS.NESTED_BASIC); const result = calculate_edge( 0.58, - 0.3, + 0.5, LAYOUTS.NESTED_BASIC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); expect(result.is_edge).toBe(true); - expect(result.slot).toBe("DDD"); + expect(result.slot).toBe("BBB"); expect(result.path).toEqual([0, 1, 1]); }); +test("summertime", () => { + const drop_target = calculate_intersection(0.8, 0.5, LAYOUTS.NESTED_BASIC); + const result = calculate_edge( + 0.8, + 0.5, + LAYOUTS.NESTED_BASIC, + "DDD", + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, + ); + + expect(result.is_edge).toBe(false); + expect(result.slot).toBe("CCC"); + expect(result.path).toEqual([1, 0]); +}); + test("complex layout with multiple nested panels", () => { const drop_target = calculate_intersection( 0.02, 0.3, LAYOUTS.COMPLEX_NESTED_ABC, - false, ); const result = calculate_edge( @@ -380,28 +439,10 @@ test("complex layout with multiple nested panels", () => { 0.3, LAYOUTS.COMPLEX_NESTED_ABC, "DDD", - drop_target, + // biome-ignore lint/style/noNonNullAssertion: playwright assertion + drop_target!, ); expect(result.is_edge).toBe(true); - expect(result.slot).toBe("DDD"); -}); - -test("preserves drop_target properties when no split occurs", () => { - const drop_target = calculate_intersection( - 0.3, - 0.3, - LAYOUTS.NESTED_BASIC, - false, - ); - const result = calculate_edge( - 0.3, - 0.3, - LAYOUTS.NESTED_BASIC, - "DDD", - drop_target, - ); - expect(result.view_window).toBeDefined(); - expect(result.path).toBeDefined(); - expect(result.orientation).toBeDefined(); + expect(result.slot).toBe("A"); }); diff --git a/tests/unit/composite.spec.ts b/tests/unit/composite.spec.ts new file mode 100644 index 0000000..7856424 --- /dev/null +++ b/tests/unit/composite.spec.ts @@ -0,0 +1,275 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ +// ┃ * of the Regular Layout library, distributed under the terms of the * ┃ +// ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import { expect, test } from "@playwright/test"; +import { LAYOUTS } from "../helpers/fixtures.ts"; +import { insert_child } from "../../src/layout/insert_child.ts"; +import type { Layout } from "../../src/layout/types.ts"; + +test("cursor near left edge of same orientation", () => { + const result = insert_child( + LAYOUTS.NESTED_BASIC, + "DDD", + [0, 1, 0], + "horizontal", + ); + + expect(result).toStrictEqual({ + type: "split-panel", + children: [ + { + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "split-panel", + orientation: "horizontal", + sizes: [0.5, 0.5], + children: [ + { + type: "child-panel", + child: ["DDD"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + }, + ], + sizes: [0.3, 0.7], + orientation: "vertical", + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.6, 0.4], + orientation: "horizontal", + }); +}); + +test("cursor near top edge of opposite orientation", () => { + const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", [0], "vertical"); + + expect(result).toStrictEqual({ + type: "split-panel", + sizes: [0.5, 0.5], + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["DDD"], + }, + { + type: "split-panel", + children: [ + { + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + orientation: "vertical", + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.6, 0.4], + orientation: "horizontal", + }, + ], + }); +}); + +test("cursor near bottom edge but with opposite orientation", () => { + const result = insert_child( + LAYOUTS.NESTED_BASIC, + "DDD", + [1], + "vertical", + // true, + ); + + expect(result).toStrictEqual({ + type: "split-panel", + sizes: [0.5, 0.5], + orientation: "vertical", + children: [ + { + type: "split-panel", + children: [ + { + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + orientation: "vertical", + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.6, 0.4], + orientation: "horizontal", + }, + { + type: "child-panel", + child: ["DDD"], + }, + ], + }); +}); + +test("", () => { + const PANEL: Layout = { + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["AAA"], + selected: 0, + }, + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["BBB"], + selected: 0, + }, + { + type: "child-panel", + child: ["CCC"], + selected: 0, + }, + ], + sizes: [0.5, 0.5], + }, + ], + sizes: [0.5, 0.5], + }, + { + type: "child-panel", + child: ["EEE"], + selected: 0, + }, + ], + sizes: [0.5, 0.5], + }, + { + type: "child-panel", + child: ["FFF", "GGG", "HHH"], + selected: 0, + }, + ], + sizes: [0.5, 0.5], + }; + + const result = insert_child(PANEL, "QQQ", [1], "vertical"); + expect(result).toStrictEqual({ + sizes: [0.5, 0.5], + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["AAA"], + selected: 0, + }, + { + type: "split-panel", + orientation: "vertical", + children: [ + { + type: "child-panel", + child: ["BBB"], + selected: 0, + }, + { + type: "child-panel", + child: ["CCC"], + selected: 0, + }, + ], + sizes: [0.5, 0.5], + }, + ], + sizes: [0.5, 0.5], + }, + { + type: "child-panel", + child: ["EEE"], + selected: 0, + }, + ], + sizes: [0.5, 0.5], + }, + { + type: "child-panel", + child: ["FFF", "GGG", "HHH"], + selected: 0, + }, + ], + sizes: [0.5, 0.5], + }, + { + type: "child-panel", + child: ["QQQ"], + // selected: 0, + }, + ], + }); +}); diff --git a/tests/unit/css_grid_layout.spec.ts b/tests/unit/css_grid_layout.spec.ts index e1fe32e..4af2180 100644 --- a/tests/unit/css_grid_layout.spec.ts +++ b/tests/unit/css_grid_layout.spec.ts @@ -12,14 +12,14 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; -import { create_css_grid_layout } from "../../src/common/generate_grid.ts"; -import type { Layout } from "../../src/common/layout_config.ts"; +import { create_css_grid_layout } from "../../src/layout/generate_grid.ts"; +import type { Layout } from "../../src/layout/types.ts"; const RESULT = ` -:host { display: grid; gap: 0px; grid-template-rows: 30% 70%; grid-template-columns: 60% 40%; } -:host ::slotted([slot=AAA]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=BBB]) { grid-column: 1; grid-row: 2; } -:host ::slotted([slot=CCC]) { grid-column: 2; grid-row: 1 / 3; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 70fr;grid-template-columns:60fr 40fr} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:1;grid-row:2} +:host ::slotted([name="CCC"]){display:flex;grid-column:2;grid-row:1 / 3} `.trim(); test("simple test", async () => { @@ -34,7 +34,7 @@ test("single child panel", () => { }; expect(create_css_grid_layout(singleChild, true)).toEqual( - `:host { display: grid; gap: 0px; grid-template-rows: 100%; grid-template-columns: 100%; }\n:host ::slotted([slot=ONLY]) { grid-column: 1; grid-row: 1; }`, + `:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:100%;grid-template-columns:100%}\n:host ::slotted([name="ONLY"]){display:flex;grid-column:1;grid-row:1}`, ); }); @@ -68,10 +68,10 @@ test("regressions", () => { expect(create_css_grid_layout(test, true)).toEqual( ` -:host { display: grid; gap: 0px; grid-template-rows: 80% 20%; grid-template-columns: 60% 40%; } -:host ::slotted([slot=AAA]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=BBB]) { grid-column: 1; grid-row: 2; } -:host ::slotted([slot=CCC]) { grid-column: 2; grid-row: 1 / 3; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:80fr 20fr;grid-template-columns:60fr 40fr} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:1;grid-row:2} +:host ::slotted([name="CCC"]){display:flex;grid-column:2;grid-row:1 / 3} `.trim(), ); }); @@ -121,12 +121,12 @@ test("deeply nested css grid", () => { expect(create_css_grid_layout(test, true)).toEqual( ` -:host { display: grid; gap: 0px; grid-template-rows: 30% 60% 10%; grid-template-columns: 30% 30% 40%; } -:host ::slotted([slot=AAA]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=EEE]) { grid-column: 2; grid-row: 1; } -:host ::slotted([slot=BBB]) { grid-column: 1 / 3; grid-row: 2; } -:host ::slotted([slot=DDD]) { grid-column: 1 / 3; grid-row: 3; } -:host ::slotted([slot=CCC]) { grid-column: 3; grid-row: 1 / 4; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 60fr 10fr;grid-template-columns:30fr 30fr 40fr} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} +:host ::slotted([name="EEE"]){display:flex;grid-column:2;grid-row:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:1 / 3;grid-row:2} +:host ::slotted([name="DDD"]){display:flex;grid-column:1 / 3;grid-row:3} +:host ::slotted([name="CCC"]){display:flex;grid-column:3;grid-row:1 / 4} `.trim(), ); }); @@ -187,13 +187,13 @@ test("Deeply nested CSS grid part 2", () => { expect(create_css_grid_layout(test, true)).toEqual( ` -:host { display: grid; gap: 0px; grid-template-rows: 30% 60% 10%; grid-template-columns: 30% 30% 20% 20%; } -:host ::slotted([slot=AAA]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=EEE]) { grid-column: 2; grid-row: 1; } -:host ::slotted([slot=BBB]) { grid-column: 1 / 3; grid-row: 2; } -:host ::slotted([slot=DDD]) { grid-column: 1 / 3; grid-row: 3; } -:host ::slotted([slot=CCC]) { grid-column: 3; grid-row: 1 / 4; } -:host ::slotted([slot=FFF]) { grid-column: 4; grid-row: 1 / 4; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 60fr 10fr;grid-template-columns:30fr 30fr 20fr 20fr} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} +:host ::slotted([name="EEE"]){display:flex;grid-column:2;grid-row:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:1 / 3;grid-row:2} +:host ::slotted([name="DDD"]){display:flex;grid-column:1 / 3;grid-row:3} +:host ::slotted([name="CCC"]){display:flex;grid-column:3;grid-row:1 / 4} +:host ::slotted([name="FFF"]){display:flex;grid-column:4;grid-row:1 / 4} `.trim(), ); }); @@ -232,11 +232,11 @@ test("parallel", () => { expect(create_css_grid_layout(test, true)).toEqual( ` -:host { display: grid; gap: 0px; grid-template-rows: 30% 70%; grid-template-columns: 33% 33% 33%; } -:host ::slotted([slot=AAA]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=BBB]) { grid-column: 1; grid-row: 2; } -:host ::slotted([slot=DDD]) { grid-column: 2; grid-row: 1 / 3; } -:host ::slotted([slot=CCC]) { grid-column: 3; grid-row: 1 / 3; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 70fr;grid-template-columns:33fr 33fr 33fr} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:1;grid-row:2} +:host ::slotted([name="DDD"]){display:flex;grid-column:2;grid-row:1 / 3} +:host ::slotted([name="CCC"]){display:flex;grid-column:3;grid-row:1 / 3} `.trim(), ); }); @@ -282,11 +282,11 @@ test("Parallel split-panels with different sizes", () => { expect(create_css_grid_layout(test, true)).toEqual( ` -:host { display: grid; gap: 0px; grid-template-rows: 30% 40% 30%; grid-template-columns: 50% 50%; } -:host ::slotted([slot=AAA]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=BBB]) { grid-column: 1; grid-row: 2 / 4; } -:host ::slotted([slot=CCC]) { grid-column: 2; grid-row: 1 / 3; } -:host ::slotted([slot=DDD]) { grid-column: 2; grid-row: 3; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:30fr 40fr 30fr;grid-template-columns:50fr 50fr} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:1;grid-row:2 / 4} +:host ::slotted([name="CCC"]){display:flex;grid-column:2;grid-row:1 / 3} +:host ::slotted([name="DDD"]){display:flex;grid-column:2;grid-row:3} `.trim(), ); }); @@ -343,12 +343,12 @@ test("Deeply alternating split", () => { expect(create_css_grid_layout(test, true)).toEqual( ` -:host { display: grid; gap: 0px; grid-template-rows: 15% 15% 70%; grid-template-columns: 30% 30% 40%; } -:host ::slotted([slot=VfssXzLK]) { grid-column: 1; grid-row: 1 / 3; } -:host ::slotted([slot=qsAwxKvs]) { grid-column: 2; grid-row: 1; } -:host ::slotted([slot=AAA]) { grid-column: 2; grid-row: 2; } -:host ::slotted([slot=BBB]) { grid-column: 1 / 3; grid-row: 3; } -:host ::slotted([slot=CCC]) { grid-column: 3; grid-row: 1 / 4; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:15fr 15fr 70fr;grid-template-columns:30fr 30fr 40fr} +:host ::slotted([name="VfssXzLK"]){display:flex;grid-column:1;grid-row:1 / 3} +:host ::slotted([name="qsAwxKvs"]){display:flex;grid-column:2;grid-row:1} +:host ::slotted([name="AAA"]){display:flex;grid-column:2;grid-row:2} +:host ::slotted([name="BBB"]){display:flex;grid-column:1 / 3;grid-row:3} +:host ::slotted([name="CCC"]){display:flex;grid-column:3;grid-row:1 / 4} `.trim(), ); }); diff --git a/tests/unit/css_grid_layout_partial.spec.ts b/tests/unit/css_grid_layout_partial.spec.ts index c9ca7fc..e827678 100644 --- a/tests/unit/css_grid_layout_partial.spec.ts +++ b/tests/unit/css_grid_layout_partial.spec.ts @@ -11,8 +11,8 @@ import { expect, test } from "@playwright/test"; -import { create_css_grid_layout } from "../../src/common/generate_grid.ts"; -import type { Layout } from "../../src/common/layout_config.ts"; +import { create_css_grid_layout } from "../../src/layout/generate_grid.ts"; +import type { Layout } from "../../src/layout/types.ts"; test("Deeply alternating split with grid-based overlay", () => { const test: Layout = { @@ -66,13 +66,14 @@ test("Deeply alternating split with grid-based overlay", () => { expect(create_css_grid_layout(test, false, ["BBB", "AAA"])).toEqual( ` -:host { display: grid; gap: 0px; grid-template-rows: 30% 70%; grid-template-columns: 30% 30% 40%; } -:host ::slotted([slot=AAA]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=BBB]) { grid-column: 1; grid-row: 1; } -:host ::slotted([slot=BBB]) { z-index: 1; } -:host ::slotted([slot=CCC]) { grid-column: 2; grid-row: 1; } -:host ::slotted([slot=DDD]) { grid-column: 1 / 3; grid-row: 2; } -:host ::slotted([slot=EEE]) { grid-column: 3; grid-row: 1 / 3; } +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:15fr 15fr 70fr;grid-template-columns:30fr 30fr 40fr} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1 / 3} +:host ::slotted([name="BBB"]){display:flex;grid-column:1;grid-row:1 / 3} +:host ::slotted([name=BBB]){z-index:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:2;grid-row:1} +:host ::slotted([name="CCC"]){display:flex;grid-column:2;grid-row:2} +:host ::slotted([name="DDD"]){display:flex;grid-column:1 / 3;grid-row:3} +:host ::slotted([name="EEE"]){display:flex;grid-column:3;grid-row:1 / 4} `.trim(), ); }); diff --git a/tests/unit/flatten.spec.ts b/tests/unit/flatten.spec.ts index 3703236..a5e5d35 100644 --- a/tests/unit/flatten.spec.ts +++ b/tests/unit/flatten.spec.ts @@ -10,8 +10,8 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { expect, test } from "@playwright/test"; -import { flatten } from "../../src/common/flatten.ts"; -import type { Layout } from "../../src/common/layout_config.ts"; +import { flatten } from "../../src/layout/flatten.ts"; +import type { Layout } from "../../src/layout/types.ts"; test("Deeply alternating split partial", () => { const test: Layout = { diff --git a/tests/unit/hit_detection.spec.ts b/tests/unit/hit_detection.spec.ts index 9a4a90b..dfc2c8e 100644 --- a/tests/unit/hit_detection.spec.ts +++ b/tests/unit/hit_detection.spec.ts @@ -10,15 +10,15 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { expect, test } from "@playwright/test"; -import { calculate_intersection } from "../../src/common/calculate_intersect.ts"; +import { calculate_intersection } from "../../src/layout/calculate_intersect.ts"; import { LAYOUTS } from "../helpers/fixtures.ts"; test("AAA", () => { const result = calculate_intersection(0.1, 0.1, LAYOUTS.NESTED_BASIC); expect(result).toStrictEqual({ slot: "AAA", - box: undefined, path: [0, 0], + layout: undefined, type: "layout-path", is_edge: false, orientation: "vertical", @@ -32,10 +32,6 @@ test("AAA", () => { row: 0.1, column_offset: 0.16666666666666669, row_offset: 0.33333333333333337, - panel: { - child: ["AAA"], - type: "child-panel", - }, }); }); @@ -44,7 +40,7 @@ test("BBB", () => { expect(result).toStrictEqual({ slot: "BBB", path: [0, 1], - box: undefined, + layout: undefined, type: "layout-path", is_edge: false, orientation: "vertical", @@ -58,10 +54,6 @@ test("BBB", () => { row: 0.4, column_offset: 0.16666666666666669, row_offset: 0.1428571428571429, - panel: { - child: ["BBB"], - type: "child-panel", - }, }); }); @@ -70,14 +62,10 @@ test("CCC", () => { expect(result).toStrictEqual({ slot: "CCC", path: [1], - box: undefined, + layout: undefined, type: "layout-path", is_edge: false, orientation: "horizontal", - panel: { - child: ["CCC"], - type: "child-panel", - }, view_window: { row_end: 1, row_start: 0, @@ -92,7 +80,10 @@ test("CCC", () => { }); test("gap", () => { - const result = calculate_intersection(0.6, 0.1, LAYOUTS.NESTED_BASIC); + const result = calculate_intersection(0.6, 0.1, LAYOUTS.NESTED_BASIC, { + width: 100, + height: 100, + } as DOMRect); expect(result).toStrictEqual({ path: [0], type: "horizontal", @@ -106,7 +97,10 @@ test("gap", () => { }); test("nested gap", () => { - const result = calculate_intersection(0.1, 0.3, LAYOUTS.NESTED_BASIC); + const result = calculate_intersection(0.1, 0.3, LAYOUTS.NESTED_BASIC, { + width: 100, + height: 100, + } as DOMRect); expect(result).toStrictEqual({ path: [0, 0], type: "vertical", @@ -118,3 +112,28 @@ test("nested gap", () => { }, }); }); + +test("single AAA", () => { + const result = calculate_intersection(0.1, 0.3, LAYOUTS.SINGLE_AAA, { + width: 100, + height: 100, + } as DOMRect); + expect(result).toStrictEqual({ + layout: undefined, + column: 0.1, + column_offset: 0.1, + is_edge: false, + orientation: "horizontal", + path: [], + row: 0.3, + row_offset: 0.3, + slot: "AAA", + type: "layout-path", + view_window: { + col_end: 1, + col_start: 0, + row_end: 1, + row_start: 0, + }, + }); +}); diff --git a/tests/unit/insert_child.spec.ts b/tests/unit/insert_child.spec.ts index 40223b6..73b9dc7 100644 --- a/tests/unit/insert_child.spec.ts +++ b/tests/unit/insert_child.spec.ts @@ -11,7 +11,12 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; -import { insert_child } from "../../src/common/insert_child.ts"; +import { insert_child } from "../../src/layout/insert_child.ts"; + +// - If the last index of the path is a split-panel, insert at that point. +// - If the last index of the path is a child panel, stack at the last position in the tab +// - If the second to last position is a child panel, stack in the position of the last index. +// - if _edge_mode_ is set, always insert in a split panel at the second to last index test("insert into root split panel", () => { const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", []); @@ -42,11 +47,255 @@ test("insert into root split panel", () => { child: ["DDD"], }, ], - sizes: [0.3333333333333333, 0.3333333333333333, 0.3333333333333333], + sizes: [0.39999999999999997, 0.26666666666666666, 0.3333333333333333], + orientation: "horizontal", + }); +}); + +test("insert into root split panel edge", () => { + const result = insert_child( + LAYOUTS.NESTED_BASIC, + "DDD", + [0], + "vertical", + // true, + ); + + expect(result).toStrictEqual({ + type: "split-panel", + orientation: "vertical", + sizes: [0.5, 0.5], + children: [ + { + type: "child-panel", + child: ["DDD"], + }, + { + type: "split-panel", + children: [ + { + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + orientation: "vertical", + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.6, 0.4], + orientation: "horizontal", + }, + ], + }); +}); + +test("insert into root split panel edge along the same orientation", () => { + const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", [0]); + expect(result).toStrictEqual({ + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["DDD"], + }, + { + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + orientation: "vertical", + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.3333333333333333, 0.39999999999999997, 0.26666666666666666], + orientation: "horizontal", + }); +}); + +test("stack split panel", () => { + const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", [0, 1, 0]); + expect(result).toStrictEqual({ + type: "split-panel", + children: [ + { + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["DDD", "BBB"], + }, + ], + sizes: [0.3, 0.7], + orientation: "vertical", + }, + { + type: "child-panel", + child: ["CCC"], + }, + ], + sizes: [0.6, 0.4], + orientation: "horizontal", + }); +}); + +test("stack split panel single child after", () => { + const result = insert_child(LAYOUTS.SINGLE_AAA, "DDD", [1]); + expect(result).toStrictEqual({ + type: "child-panel", + child: ["AAA", "DDD"], + }); +}); + +test("stack split panel single child before", () => { + const result = insert_child(LAYOUTS.SINGLE_AAA, "DDD", [0]); + expect(result).toStrictEqual({ + type: "child-panel", + child: ["DDD", "AAA"], + }); +}); + +test("stack split panel two horizontal before", () => { + const result = insert_child(LAYOUTS.TWO_HORIZONTAL, "DDD", [0, 0]); + expect(result).toStrictEqual({ + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["DDD", "AAA"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + }); +}); + +test("stack split panel two horizontal after", () => { + const result = insert_child(LAYOUTS.TWO_HORIZONTAL, "DDD", [0, 1]); + expect(result).toStrictEqual({ + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["AAA", "DDD"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + }); +}); + +test("stack nested basic after", () => { + const result = insert_child(LAYOUTS.TWO_HORIZONTAL, "DDD", [0, 1]); + expect(result).toStrictEqual({ + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["AAA", "DDD"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + }); +}); + +test("stack nested basic after 5", () => { + const result = insert_child( + LAYOUTS.NESTED_BASIC, + "DDD", + [1, 0], + // "horizontal", + // false, + ); + expect(result).toStrictEqual({ + type: "split-panel", + children: [ + { + type: "split-panel", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3, 0.7], + orientation: "vertical", + }, + { + type: "child-panel", + child: ["DDD", "CCC"], + }, + ], + sizes: [0.6, 0.4], orientation: "horizontal", }); }); +test("stack nested basic after 4", () => { + const result = insert_child(LAYOUTS.TWO_HORIZONTAL_EQUAL, "DDD", [1]); + expect(result).toStrictEqual({ + type: "split-panel", + orientation: "horizontal", + children: [ + { + type: "child-panel", + child: ["AAA"], + }, + { + type: "child-panel", + child: ["DDD"], + }, + { + type: "child-panel", + child: ["BBB"], + }, + ], + sizes: [0.3333333333333333, 0.3333333333333333, 0.3333333333333333], + }); +}); + test("append top level split-panel", () => { const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", [2]); expect(result).toStrictEqual({ @@ -76,7 +325,7 @@ test("append top level split-panel", () => { child: ["DDD"], }, ], - sizes: [0.3333333333333333, 0.3333333333333333, 0.3333333333333333], + sizes: [0.39999999999999997, 0.26666666666666666, 0.3333333333333333], orientation: "horizontal", }); }); @@ -110,19 +359,18 @@ test("insert into top level split-panel", () => { child: ["CCC"], }, ], - sizes: [0.3333333333333333, 0.3333333333333333, 0.3333333333333333], + sizes: [0.39999999999999997, 0.3333333333333333, 0.26666666666666666], orientation: "horizontal", }); }); -test("insert at path splitting a child panel", () => { - const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", [1, 1]); +test("insert into top level split-panel 2", () => { + const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", [1, 0]); expect(result).toStrictEqual({ type: "split-panel", children: [ { type: "split-panel", - orientation: "vertical", children: [ { type: "child-panel", @@ -134,21 +382,41 @@ test("insert at path splitting a child panel", () => { }, ], sizes: [0.3, 0.7], + orientation: "vertical", }, + { + type: "child-panel", + child: ["DDD", "CCC"], + }, + ], + sizes: [0.6, 0.4], + orientation: "horizontal", + }); +}); + +test("insert at path splitting a child panel", () => { + const result = insert_child(LAYOUTS.NESTED_BASIC, "DDD", [1, 1]); + expect(result).toStrictEqual({ + type: "split-panel", + children: [ { type: "split-panel", orientation: "vertical", children: [ { type: "child-panel", - child: ["CCC"], + child: ["AAA"], }, { type: "child-panel", - child: ["DDD"], + child: ["BBB"], }, ], - sizes: [0.5, 0.5], + sizes: [0.3, 0.7], + }, + { + type: "child-panel", + child: ["CCC", "DDD"], }, ], sizes: [0.6, 0.4], @@ -177,7 +445,7 @@ test("insert into nested split panel", () => { child: ["DDD"], }, ], - sizes: [0.3333333333333333, 0.3333333333333333, 0.3333333333333333], + sizes: [0.19999999999999998, 0.4666666666666666, 0.3333333333333333], orientation: "vertical", }, { @@ -199,19 +467,8 @@ test("split a nested child panel", () => { type: "split-panel", children: [ { - type: "split-panel", - orientation: "horizontal", - children: [ - { - type: "child-panel", - child: ["AAA"], - }, - { - type: "child-panel", - child: ["DDD"], - }, - ], - sizes: [0.5, 0.5], + type: "child-panel", + child: ["AAA", "DDD"], }, { type: "child-panel", @@ -232,7 +489,7 @@ test("split a nested child panel", () => { }); test("insert into single child panel", () => { - const result = insert_child(LAYOUTS.SINGLE_ONLY, "SECOND", [], "horizontal"); + const result = insert_child(LAYOUTS.SINGLE_ONLY, "SECOND", []); expect(result).toStrictEqual({ type: "child-panel", child: ["SECOND", "ONLY"], @@ -240,9 +497,32 @@ test("insert into single child panel", () => { }); test("insert into single child panel, on the top edge", () => { - const result = insert_child(LAYOUTS.SINGLE_ONLY, "SECOND", [0], "vertical"); + const result = insert_child( + LAYOUTS.SINGLE_ONLY, + "SECOND", + [0], + // "vertical", + // true, + ); + + expect(result).toStrictEqual({ + type: "child-panel", + child: ["SECOND", "ONLY"], + }); +}); + +test("insert into single child panel, on the top edge with split", () => { + const result = insert_child( + LAYOUTS.SINGLE_ONLY, + "SECOND", + [0], + "vertical", + // true, + ); + expect(result).toStrictEqual({ type: "split-panel", + sizes: [0.5, 0.5], orientation: "vertical", children: [ { @@ -254,31 +534,32 @@ test("insert into single child panel, on the top edge", () => { child: ["ONLY"], }, ], - sizes: [0.5, 0.5], }); }); test("insert into single child panel, on the left edge", () => { - const result = insert_child(LAYOUTS.SINGLE_ONLY, "SECOND", [0], "horizontal"); + const result = insert_child( + LAYOUTS.SINGLE_ONLY, + "SECOND", + [1], + // "vertical", + // true, + ); + expect(result).toStrictEqual({ - type: "split-panel", - orientation: "horizontal", - children: [ - { - type: "child-panel", - child: ["SECOND"], - }, - { - type: "child-panel", - child: ["ONLY"], - }, - ], - sizes: [0.5, 0.5], + type: "child-panel", + child: ["ONLY", "SECOND"], }); }); test("insert into single child panel, on the bottom edge", () => { - const result = insert_child(LAYOUTS.SINGLE_ONLY, "SECOND", [1], "vertical"); + const result = insert_child( + LAYOUTS.SINGLE_ONLY, + "SECOND", + [1], + "vertical", + // true, + ); expect(result).toStrictEqual({ type: "split-panel", orientation: "vertical", @@ -297,7 +578,13 @@ test("insert into single child panel, on the bottom edge", () => { }); test("insert into single child panel, on the right edge", () => { - const result = insert_child(LAYOUTS.SINGLE_ONLY, "SECOND", [1], "horizontal"); + const result = insert_child( + LAYOUTS.SINGLE_ONLY, + "SECOND", + [1], + "horizontal", + // true, + ); expect(result).toStrictEqual({ type: "split-panel", orientation: "horizontal", @@ -316,6 +603,14 @@ test("insert into single child panel, on the right edge", () => { }); test("insert into a child-panel root, on the top edge", () => { + const result = insert_child(LAYOUTS.SINGLE_AAA, "BBB", [0]); + expect(result).toStrictEqual({ + type: "child-panel", + child: ["BBB", "AAA"], + }); +}); + +test("insert into a child-panel root, on the top edge with split", () => { const result = insert_child(LAYOUTS.SINGLE_AAA, "BBB", [0], "vertical"); expect(result).toStrictEqual({ type: "split-panel", @@ -334,31 +629,39 @@ test("insert into a child-panel root, on the top edge", () => { }); }); +test("insert into SINGLE_TABS", () => { + const result = insert_child(LAYOUTS.SINGLE_TABS, "DDD", [1]); + expect(result).toStrictEqual({ + type: "child-panel", + child: ["AAA", "DDD", "BBB", "CCC"], + selected: 0, + }); +}); + test("insert with split path into SINGLE_TABS", () => { - const result = insert_child(LAYOUTS.SINGLE_TABS, "DDD", [0, 1], "horizontal"); + const result = insert_child( + LAYOUTS.SINGLE_TABS, + "DDD", + [1], + "vertical", + // true, + ); + expect(result).toStrictEqual({ type: "split-panel", - orientation: "horizontal", + sizes: [0.5, 0.5], + orientation: "vertical", children: [ { - type: "split-panel", - orientation: "vertical", - children: [ - { - type: "child-panel", - child: ["AAA", "BBB", "CCC"], - selected: 0, - }, - { - type: "child-panel", - child: ["DDD"], - // TODO this one case does not call flatten internally for performance - // selected: 0, - }, - ], - sizes: [0.5, 0.5], + type: "child-panel", + child: ["AAA", "BBB", "CCC"], + selected: 0, + }, + { + type: "child-panel", + child: ["DDD"], + // selected: 0, }, ], - sizes: [1], }); }); diff --git a/tests/unit/redistribute_panel_sizes.spec.ts b/tests/unit/redistribute_panel_sizes.spec.ts index de4b52a..19f9ac5 100644 --- a/tests/unit/redistribute_panel_sizes.spec.ts +++ b/tests/unit/redistribute_panel_sizes.spec.ts @@ -11,7 +11,7 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; -import { redistribute_panel_sizes } from "../../src/common/redistribute_panel_sizes.ts"; +import { redistribute_panel_sizes } from "../../src/layout/redistribute_panel_sizes.ts"; test("redistribute depth 1 child", () => { const clone = redistribute_panel_sizes( diff --git a/tests/unit/remove_child.spec.ts b/tests/unit/remove_child.spec.ts index 5376d2c..45edb67 100644 --- a/tests/unit/remove_child.spec.ts +++ b/tests/unit/remove_child.spec.ts @@ -11,7 +11,7 @@ import { expect, test } from "@playwright/test"; import { LAYOUTS } from "../helpers/fixtures.ts"; -import { remove_child } from "../../src/common/remove_child.ts"; +import { remove_child } from "../../src/layout/remove_child.ts"; test("remove child from nested split panel", () => { const result = remove_child(LAYOUTS.NESTED_BASIC, "AAA"); diff --git a/themes/chicago.css b/themes/chicago.css new file mode 100644 index 0000000..e3234d5 --- /dev/null +++ b/themes/chicago.css @@ -0,0 +1,89 @@ +/* ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ + * ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ + * ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ + * ┃ * of the Regular Layout library, distributed under the terms of the * ┃ + * ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +regular-layout.chicago { + background-color: #008080; + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + padding: 24px; +} + +/* Frame */ +regular-layout.chicago regular-layout-frame { + position: relative; + box-sizing: border-box; + margin: 12px; + background: #C0C0C0; + border-width: 2px; + border-color: #FFFFFF #808080 #808080 #FFFFFF; + border-style: solid; +} + +regular-layout.chicago regular-layout-frame::part(close) { + border-width: 1px; + border-color: #FFFFFF #808080 #808080 #FFFFFF; + border-style: solid; + height: 16px; + background: #C0C0C0; + align-self: center; + display: flex; + align-items: center; + padding: 0px 4px; +} + +regular-layout.chicago regular-layout-frame::part(close):before { + content: "X"; + font-size: 10px; + font-weight: bold; +} + +regular-layout.chicago regular-layout-frame::part(titlebar) { + display: flex; + align-items: stretch; + padding-right: 0px; +} + +regular-layout.chicago regular-layout-frame::part(tab) { + display: flex; + flex: 1 1 150px; + align-items: center; + padding: 0 3px 0 8px; + cursor: pointer; + text-overflow: ellipsis; + background: #808080; + color: #FFF; + font-family: "Tahoma", "Arial", sans-serif; + font-weight: bold; + font-size: 11px; +} + +regular-layout.chicago regular-layout-frame::part(active-tab) { + background: #000080; + opacity: 1; +} + +regular-layout.chicago:has(.overlay)>* { + opacity: 0.8; +} + +regular-layout.chicago:has(.overlay)>.overlay { + opacity: 1; +} + +/* Frame in Overlay Mode */ +regular-layout.chicago regular-layout-frame.overlay { + margin: 0; + transition: + top 0.1s ease-in-out, + height 0.1s ease-in-out, + width 0.1s ease-in-out, + left 0.1s ease-in-out; +} \ No newline at end of file diff --git a/themes/fluxbox.css b/themes/fluxbox.css new file mode 100644 index 0000000..ab039fc --- /dev/null +++ b/themes/fluxbox.css @@ -0,0 +1,110 @@ +/* ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ + * ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ + * ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ + * ┃ * of the Regular Layout library, distributed under the terms of the * ┃ + * ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +regular-layout.fluxbox { + background-color: #DBDFE6; + font-family: "ui-sans-serif", "Helvetica", "Arial", sans-serif; + padding: 16px; +} + +/* Frame */ +regular-layout.fluxbox regular-layout-frame { + position: relative; + box-sizing: border-box; + margin: 8px; + background: #FFFFFF; + border: 1px solid #9DACBE; + box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.15); +} + +regular-layout.fluxbox regular-layout-frame::part(close) { + border: 1px solid #8A96A3; + height: 14px; + background: linear-gradient(to bottom, #E8ECEF 0%, #CDD5DD 100%); + align-self: center; + display: flex; + align-items: center; + justify-content: center; + padding: 0px; + width: 14px; + margin-right: 2px; +} + +regular-layout.fluxbox regular-layout-frame::part(close):hover { + background: linear-gradient(to bottom, #F0F3F5 0%, #D8DFE6 100%); +} + +regular-layout.fluxbox regular-layout-frame::part(close):active { + background: linear-gradient(to bottom, #C5CFD9 0%, #B3BEC9 100%); +} + +regular-layout.fluxbox regular-layout-frame::part(close):before { + content: "×"; + font-size: 14px; + font-weight: normal; + color: #444444; + line-height: 1; +} + +regular-layout.fluxbox regular-layout-frame::part(titlebar) { + display: flex; + align-items: stretch; + padding-right: 0px; + height: 22px; +} + +regular-layout.fluxbox regular-layout-frame::part(tab) { + display: flex; + flex: 1 1 150px; + align-items: center; + padding: 0 8px; + cursor: pointer; + text-overflow: ellipsis; + background: linear-gradient(to bottom, #C7D1DB 0%, #B3BEC9 100%); + color: #4A4A4A; + font-size: 11px; + font-weight: normal; + border-right: 1px solid #9DACBE; + opacity: 0.85; +} + +regular-layout.fluxbox regular-layout-frame::part(tab):hover { + background: linear-gradient(to bottom, #D2DBE4 0%, #BEC9D4 100%); +} + +regular-layout.fluxbox regular-layout-frame::part(active-tab) { + background: linear-gradient(to bottom, #E0E7EF 0%, #D1DAE3 100%); + color: #1A1A1A; + opacity: 1; + font-weight: 500; +} + +regular-layout.fluxbox:has(.overlay)>* { + opacity: 0.7; +} + +regular-layout.fluxbox:has(.overlay)>.overlay { + opacity: 1; +} + +/* Frame in Overlay Mode */ +regular-layout.fluxbox regular-layout-frame.overlay { + margin: 0; + background-color: rgba(155, 172, 190, 0.25); + border: 1px solid #6B7C8F; + box-shadow: none; + transition: + top 0.1s ease-in-out, + height 0.1s ease-in-out, + width 0.1s ease-in-out, + left 0.1s ease-in-out; +} diff --git a/themes/gibson.css b/themes/gibson.css new file mode 100644 index 0000000..8e0fc6d --- /dev/null +++ b/themes/gibson.css @@ -0,0 +1,264 @@ +/* ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ + * ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ + * ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ + * ┃ * of the Regular Layout library, distributed under the terms of the * ┃ + * ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +regular-layout.gibson { + background: radial-gradient(ellipse at center, #0a0a1a 0%, #000000 100%); + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + padding: 8px; +} + +/* Frame */ +regular-layout.gibson regular-layout-frame { + --titlebar-height: 28px; + position: relative; + box-sizing: border-box; + margin: 8px; + background: rgba(10, 10, 26, 0.85); + backdrop-filter: blur(4px); +} + +regular-layout.gibson regular-layout-frame::part(active-close) { + border: 1px solid #ff00ff; + height: 18px; + width: 18px; + background: rgba(255, 0, 255, 0.1); + align-self: center; + /* biome-ignore lint/complexity/noImportantStyles: reasons */ + display: flex !important; + align-items: center; + justify-content: center; + margin-right: 1px; + box-shadow: 0 0 8px rgba(255, 0, 255, 0.4); + transition: all 0.1s ease; +} + +regular-layout.gibson regular-layout-frame::part(active-close):hover { + background: rgba(255, 0, 255, 0.3); + box-shadow: 0 0 12px rgba(255, 0, 255, 0.8); +} + +regular-layout.gibson regular-layout-frame::part(active-close):before { + content: "×"; + font-size: 14px; + font-weight: bold; + color: #ff00ff; + text-shadow: 0 0 4px rgba(255, 0, 255, 0.8); +} + +regular-layout.gibson regular-layout-frame::part(close) { + display: none; +} + + +regular-layout.gibson regular-layout-frame::part(titlebar) { + display: flex; + justify-content: flex-end; + align-items: stretch; + padding-left: 12px; + height: 32px; + + /* border-bottom: 1px solid rgba(0, 255, 255, 0.3); */ +} + +regular-layout.gibson regular-layout-frame::part(tab) { + display: flex; + flex: 1 1 150px; + max-width: 200px; + align-items: center; + padding: 0 5px 0 12px; + cursor: pointer; + text-overflow: ellipsis; + background: rgba(255, 0, 255, 0.1); + color: #00ffff; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.5px; + text-transform: uppercase; + /* border-right: 1px solid rgba(0, 255, 255, 0.2); */ + opacity: 0.6; + transition: all 0.2s ease; + text-shadow: 0 0 4px rgba(0, 255, 255, 0.5); +} + +regular-layout.gibson regular-layout-frame::part(tab):hover { + box-shadow: inset 0 0 12px rgba(0, 255, 255, 0.9); +} + +regular-layout.gibson regular-layout-frame::part(active-tab) { + background: rgba(0, 255, 255, 0.15); + opacity: 1; + border: 2px solid #00ffff; + border-bottom-width: 1px; + box-shadow: + inset 0 0 12px rgba(0, 255, 255, 0.3), + 0 0 8px rgba(0, 255, 255, 0.4); +} + +regular-layout.gibson:has(.overlay) > * { + opacity: 0.5; +} + +regular-layout.gibson:has(.overlay) > .overlay { + opacity: 1; +} + +/* Frame in Overlay Mode */ +regular-layout.gibson regular-layout-frame.overlay { + background: rgba(255, 0, 255, 0.1); + border: 1px dashed #ff00ff; + border-radius: 12px; + box-shadow: + 0 0 15px rgba(255, 0, 255, 0.6), + inset 0 0 20px rgba(255, 0, 255, 0.2); + margin: 0; + transition: + top 0.075s ease-out, + height 0.075s ease-out, + width 0.075s ease-out, + left 0.075s ease-out; +} + +regular-layout.gibson regular-layout-frame::part(container) { + display: none; + border: 1px solid #00ffff; + border-radius: 12px 0 12px 0; +} + +regular-layout.gibson regular-layout-frame::part(titlebar) { + display: none; +} + +regular-layout.gibson regular-layout-frame:not(.overlay)::part(container), +regular-layout.gibson regular-layout-frame:not(.overlay)::part(titlebar) { + display: flex; +} + +/* Cyberpunk color variations */ +regular-layout.gibson :nth-child(6n+1)::part(container) { + border-color: #00ffff; + box-shadow: + 0 0 10px rgba(0, 255, 255, 0.75), + inset 0 0 20px rgba(0, 255, 255, 0.1); +} + +regular-layout.gibson :nth-child(6n+1)::part(tab) { + color: #00ffff; + text-shadow: 0 0 4px rgba(0, 255, 255, 0.6); +} + +regular-layout.gibson :nth-child(6n+1)::part(active-tab) { + background: #00ffff44; + border-color: #00ffff; + box-shadow: + inset 0 0 12px rgba(0, 255, 255, 0.3), + 0 0 8px rgba(0, 255, 255, 0.4); +} + +regular-layout.gibson :nth-child(6n+2)::part(container) { + border-color: #ff00ff; + box-shadow: + 0 0 10px rgba(255, 0, 255, 0.75), + inset 0 0 20px rgba(255, 0, 255, 0.1); +} + +regular-layout.gibson :nth-child(6n+2)::part(tab) { + color: #ff00ff; + text-shadow: 0 0 4px rgba(255, 0, 255, 0.6); +} + +regular-layout.gibson :nth-child(6n+2)::part(active-tab) { + background: #ff00ff44; + border-color: #ff00ff; + box-shadow: + inset 0 0 12px rgba(255, 0, 255, 0.3), + 0 0 8px rgba(255, 0, 255, 0.4); +} + +regular-layout.gibson :nth-child(6n+3)::part(container) { + border-color: #00ff00; + box-shadow: + 0 0 10px rgba(0, 255, 0, 0.75), + inset 0 0 20px rgba(0, 255, 0, 0.1); +} + +regular-layout.gibson :nth-child(6n+3)::part(tab) { + color: #00ff00; + text-shadow: 0 0 4px rgba(0, 255, 0, 0.6); +} + +regular-layout.gibson :nth-child(6n+3)::part(active-tab) { + background: #00ff0044; + border-color: #00ff00; + box-shadow: + inset 0 0 12px rgba(0, 255, 0, 0.3), + 0 0 8px rgba(0, 255, 0, 0.4); +} + +regular-layout.gibson :nth-child(6n+4)::part(container) { + border-color: #ff0080; + box-shadow: + 0 0 10px rgba(255, 0, 128, 0.75), + inset 0 0 20px rgba(255, 0, 128, 0.1); +} + +regular-layout.gibson :nth-child(6n+4)::part(tab) { + color: #ff0080; + text-shadow: 0 0 4px rgba(255, 0, 128, 0.6); +} + +regular-layout.gibson :nth-child(6n+4)::part(active-tab) { + background: #ff008044; + border-color: #ff0080; + box-shadow: + inset 0 0 12px rgba(255, 0, 128, 0.3), + 0 0 8px rgba(255, 0, 128, 0.4); +} + +regular-layout.gibson :nth-child(6n+5)::part(container) { + border-color: #8000ff; + box-shadow: + 0 0 10px rgba(128, 0, 255, 0.75), + inset 0 0 20px rgba(128, 0, 255, 0.1); +} + +regular-layout.gibson :nth-child(6n+5)::part(tab) { + color: #8000ff; + text-shadow: 0 0 4px rgba(128, 0, 255, 0.6); +} + +regular-layout.gibson :nth-child(6n+5)::part(active-tab) { + background: #8000ff44; + border-color: #8000ff; + box-shadow: + inset 0 0 12px rgba(128, 0, 255, 0.3), + 0 0 8px rgba(128, 0, 255, 0.4); +} + +regular-layout.gibson :nth-child(6n+6)::part(container) { + border-color: #ffff00; + box-shadow: + 0 0 10px rgba(255, 255, 0, 0.75), + inset 0 0 20px rgba(255, 255, 0, 0.1); +} + +regular-layout.gibson :nth-child(6n+6)::part(tab) { + color: #ffff00; + text-shadow: 0 0 4px rgba(255, 255, 0, 0.6); +} + +regular-layout.gibson :nth-child(6n+6)::part(active-tab) { + background: #ffff0044; + border-color: #ffff00; + box-shadow: + inset 0 0 12px rgba(255, 255, 0, 0.3), + 0 0 8px rgba(255, 255, 0, 0.4); +} diff --git a/themes/hotdog.css b/themes/hotdog.css new file mode 100644 index 0000000..b4fefd6 --- /dev/null +++ b/themes/hotdog.css @@ -0,0 +1,88 @@ +/* ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ + * ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ + * ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ + * ┃ * of the Regular Layout library, distributed under the terms of the * ┃ + * ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +regular-layout.hotdog { + background-color: #FF0000; + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; + padding: 24px; +} + +/* Frame */ +regular-layout.hotdog regular-layout-frame { + margin: 12px; + background: #FFFF00; + border-width: 2px; + border-color: #FF6B6B #8B0000 #8B0000 #FF6B6B; + border-style: solid; +} + +regular-layout.hotdog regular-layout-frame::part(close) { + border-width: 1px; + border-color: #FF6B6B #8B0000 #8B0000 #FF6B6B; + border-style: solid; + height: 16px; + background: #FF0000; + align-self: center; + display: flex; + align-items: center; + padding: 0px 4px; + color: #FFFF00; +} + +regular-layout.hotdog regular-layout-frame::part(close):before { + content: "X"; + font-size: 10px; + font-weight: bold; +} + +regular-layout.hotdog regular-layout-frame::part(titlebar) { + display: flex; + align-items: stretch; + padding-right: 0px; +} + +regular-layout.hotdog regular-layout-frame::part(tab) { + display: flex; + flex: 1 1 150px; + align-items: center; + padding: 0 3px 0 8px; + cursor: pointer; + text-overflow: ellipsis; + background: #CC0000; + color: #FFFF00; + font-family: "Tahoma", "Arial", sans-serif; + font-weight: bold; + font-size: 11px; +} + +regular-layout.hotdog regular-layout-frame::part(active-tab) { + background: #FF0000; + opacity: 1; +} + +regular-layout.hotdog:has(.overlay)>* { + opacity: 0.8; +} + +regular-layout.hotdog:has(.overlay)>.overlay { + opacity: 1; +} + +/* Frame in Overlay Mode */ +regular-layout.hotdog regular-layout-frame.overlay { + margin: 0; + transition: + top 0.1s ease-in-out, + height 0.1s ease-in-out, + width 0.1s ease-in-out, + left 0.1s ease-in-out; +} diff --git a/themes/lorax.css b/themes/lorax.css new file mode 100644 index 0000000..46cf0ad --- /dev/null +++ b/themes/lorax.css @@ -0,0 +1,129 @@ +/* ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ + * ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ + * ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ + * ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃ + * ┃ * of the Regular Layout library, distributed under the terms of the * ┃ + * ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + */ + +regular-layout.lorax { + font-family: "ui-monospace", "SFMono-Regular", "SF Mono", "Menlo", "Consolas", "Liberation Mono", monospace; +} + +/* Frame */ +regular-layout.lorax regular-layout-frame { + margin: 3px; + margin-top: 27px; + border-radius: 0 0 6px 6px; + border: 1px solid #666; + box-shadow: 0px 6px 6px -4px rgba(150, 150, 180); +} + +regular-layout.lorax regular-layout-frame::part(titlebar) { + display: flex; + align-items: stretch; + margin-left: -1px; + margin-right: -1px; + margin-bottom: 0px; + margin-top: -24px; +} + +regular-layout.lorax regular-layout-frame::part(tab) { + display: flex; + flex: 1 1 150px; + align-items: center; + text-align: center; + font-size: 10px; + padding: 0 6px; + cursor: pointer; + max-width: 150px; + text-overflow: ellipsis; + border: 1px solid #666; + border-radius: 6px 6px 0 0; + opacity: 0.5; +} + +regular-layout.lorax regular-layout-frame::part(active-tab) { + opacity: 1; +} + +regular-layout.lorax regular-layout-frame::part(close) { + border-radius: 7px; + border: 1px solid #666; + background: transparent; + height: 14px; + align-self: center +} + + +regular-layout.lorax regular-layout-frame::part(close):hover { + transition: background-color 0.2s; + background-color: rgba(255,0,0,0.2); +} + +/* Frame in Overlay Mode */ +regular-layout.lorax regular-layout-frame.overlay { + background-color: rgba(0, 0, 0, 0.2); + border: 1px dashed rgb(0, 0, 0); + border-radius: 6px; + margin: 0; + box-shadow: none; + transition: + top 0.1s ease-in-out, + height 0.1s ease-in-out, + width 0.1s ease-in-out, + left 0.1s ease-in-out; +} + +regular-layout.lorax regular-layout-frame::part(container), regular-layout.lorax regular-layout-frame::part(titlebar) { + display: none; +} + +regular-layout.lorax regular-layout-frame:not(.overlay)::part(container), regular-layout.lorax regular-layout-frame:not(.overlay)::part(titlebar) { + display: flex; +} + +/* Colors */ +regular-layout.lorax :nth-child(8n+1), +regular-layout.lorax :nth-child(8n+1)::part(tab) { + background-color: #ffadadff; +} + +regular-layout.lorax :nth-child(8n+2), +regular-layout.lorax :nth-child(8n+2)::part(tab) { + background-color: #ffd6a5ff; +} + +regular-layout.lorax :nth-child(8n+3), +regular-layout.lorax :nth-child(8n+3)::part(tab) { + background-color: #fdffb6ff; +} + +regular-layout.lorax :nth-child(8n+4), +regular-layout.lorax :nth-child(8n+4)::part(tab) { + background-color: #caffbfff; +} + +regular-layout.lorax :nth-child(8n+5), +regular-layout.lorax :nth-child(8n+5)::part(tab) { + background-color: #9bf6ffff; +} + +regular-layout.lorax :nth-child(8n+6), +regular-layout.lorax :nth-child(8n+6)::part(tab) { + background-color: #a0c4ffff; +} + +regular-layout.lorax :nth-child(8n+7), +regular-layout.lorax :nth-child(8n+7)::part(tab) { + background-color: #bdb2ffff; +} + +regular-layout.lorax :nth-child(8n+8), +regular-layout.lorax :nth-child(8n+8)::part(tab) { + background-color: #ffc6ffff; +} \ No newline at end of file