diff --git a/src/layout/generate_grid.ts b/src/layout/generate_grid.ts index 3e49721..ecaa954 100644 --- a/src/layout/generate_grid.ts +++ b/src/layout/generate_grid.ts @@ -18,6 +18,7 @@ interface GridCell { colEnd: number; rowStart: number; rowEnd: number; + zIndex?: number; } function dedupe_sort(physics: Physics, result: number[], pos: number) { @@ -102,15 +103,26 @@ function build_cells( ): GridCell[] { if (panel.type === "tab-layout") { const selected = panel.selected ?? 0; - return [ - { - child: panel.tabs[selected], - colStart: find_track_index(physics, colPositions, colStart), - colEnd: find_track_index(physics, colPositions, colEnd), - rowStart: find_track_index(physics, rowPositions, rowStart), - rowEnd: find_track_index(physics, rowPositions, rowEnd), - }, - ]; + const col_start = find_track_index(physics, colPositions, colStart); + const col_end = find_track_index(physics, colPositions, colEnd); + const row_start = find_track_index(physics, rowPositions, rowStart); + const row_end = find_track_index(physics, rowPositions, rowEnd); + + // Stacked tabs all occupy the same cell, overlapping. Only the selected + // frame is lifted (`z-index:1`) so its content sits on top; the rest + // self-hide their content and just contribute their tab. Keeping the + // max frame `z-index` at 1 lets the drag overlay sit above any stack + // with a small constant (2). A single-tab stack + // emits one placement with no `z-index`, so non-stacked output is + // unchanged. + return panel.tabs.map((child, i) => ({ + child, + colStart: col_start, + colEnd: col_end, + rowStart: row_start, + rowEnd: row_end, + zIndex: panel.tabs.length > 1 && i === selected ? 1 : undefined, + })); } const { children, sizes, orientation } = panel; @@ -162,8 +174,11 @@ const child_template = ( slot: string, rowPart: string, colPart: string, + zIndex?: number, ) => - `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:flex;grid-column:${colPart};grid-row:${rowPart}}`; + `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:flex;grid-column:${colPart};grid-row:${rowPart}${ + zIndex === undefined ? "" : `;z-index:${zIndex}` + }}`; /** * Generates CSS Grid styles to render a layout tree. @@ -202,9 +217,18 @@ export function create_css_grid_layout( ): string { if (layout.type === "tab-layout") { const selected = layout.selected ?? 0; + const stacked = layout.tabs.length > 1; return [ host_template("100%", "100%"), - child_template(physics, layout.tabs[selected], "1", "1"), + ...layout.tabs.map((tab, i) => + child_template( + physics, + tab, + "1", + "1", + stacked && i === selected ? 1 : undefined, + ), + ), ].join("\n"); } @@ -253,11 +277,13 @@ export function create_css_grid_layout( for (const cell of cells) { const colPart = formatGridLine(cell.colStart, cell.colEnd); const rowPart = formatGridLine(cell.rowStart, cell.rowEnd); - css.push(child_template(physics, cell.child, rowPart, colPart)); + css.push( + child_template(physics, cell.child, rowPart, colPart, cell.zIndex), + ); if (cell.child === overlay?.[1]) { css.push(child_template(physics, overlay[0], rowPart, colPart)); css.push( - `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}=${overlay[0]}]){z-index:1}`, + `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}=${overlay[0]}]){z-index:2}`, ); } } diff --git a/src/layout/generate_overlay.ts b/src/layout/generate_overlay.ts index 65f14ee..952e70f 100644 --- a/src/layout/generate_overlay.ts +++ b/src/layout/generate_overlay.ts @@ -69,6 +69,6 @@ export function updateOverlaySheet( margin, ); - const css = `display:flex;position:absolute!important;z-index:1;top:${local.y}px;left:${local.x}px;height:${local.height}px;width:${local.width}px;`; + const css = `display:flex;position:absolute!important;z-index:2;top:${local.y}px;left:${local.x}px;height:${local.height}px;width:${local.width}px;`; return `::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){${css}}`; } diff --git a/src/regular-layout-frame.ts b/src/regular-layout-frame.ts index 3373011..005dec6 100644 --- a/src/regular-layout-frame.ts +++ b/src/regular-layout-frame.ts @@ -15,17 +15,19 @@ import type { RegularLayout } from "./regular-layout.ts"; 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;} +:host{box-sizing:border-box;flex-direction:column;pointer-events:none} +[part~="titlebar"]{height:24px;user-select:none;overflow:hidden} +[part~="container"]{flex:1 1 auto;pointer-events:auto} +[part~="title"]{flex:1 1 auto;pointer-events:none} +[part~="close"]{align-self:stretch} +:host([inactive]) [part~="container"]{display:none} +.tabs{display:grid;width:100%;height:100%} +.tabs slot[name="tab"]{display:grid;grid-row:1;pointer-events:auto} +.tabs regular-layout-tab{display:flex;overflow:hidden} `; const HTML_TEMPLATE = ` -
+
`; @@ -82,11 +84,11 @@ const title_variable = (slot: string): string => export class RegularLayoutFrame extends HTMLElement { private _shadowRoot!: ShadowRoot; private _container_sheet!: CSSStyleSheet; - private _title_sheet!: CSSStyleSheet; + private _tab_sheet!: CSSStyleSheet; private _layout!: RegularLayout; private _header!: HTMLElement; + private _default_tab!: RegularLayoutTab; private _drag: DragState | null = null; - private _tab_to_index_map: WeakMap = new WeakMap(); /** * Initializes this elements. Override this method and @@ -96,15 +98,20 @@ export class RegularLayoutFrame extends HTMLElement { connectedCallback() { this._container_sheet ??= new CSSStyleSheet(); this._container_sheet.replaceSync(CSS); - this._title_sheet ??= new CSSStyleSheet(); + this._tab_sheet ??= new CSSStyleSheet(); this._shadowRoot ??= this.attachShadow({ mode: "open" }); this._shadowRoot.adoptedStyleSheets = [ this._container_sheet, - this._title_sheet, + this._tab_sheet, ]; this._shadowRoot.innerHTML = HTML_TEMPLATE; this._layout = this.parentElement as RegularLayout; this._header = this._shadowRoot.children[0] as HTMLElement; + // The built-in tab is the `slot="tab"` fallback; a consumer-supplied + // `slot="tab"` child replaces it. + this._default_tab = this._header.querySelector( + "regular-layout-tab", + ) as RegularLayoutTab; this._header.addEventListener("pointerdown", this.onPointerDown); this.addEventListener("pointermove", this.onPointerMove); this.addEventListener("pointerup", this.onPointerUp); @@ -140,9 +147,17 @@ export class RegularLayoutFrame extends HTMLElement { const elem = event.target as RegularLayoutTab; if (elem.part.contains("tab")) { + const slot = this.getAttribute( + this._layout.savePhysics().CHILD_ATTRIBUTE_NAME, + ); + const path = this._layout.calculateIntersect(event); - if (path) { - this._drag = { path }; + if (path && slot) { + // Drag *this frame's* panel, not whichever panel is front-most at + // the pointer (which is what `calculateIntersect` resolves to in a + // stack). The overlapping stack shares geometry, so only the slot + // needs overriding. + this._drag = { path: { ...path, slot } }; this.setPointerCapture(event.pointerId); event.preventDefault(); } else { @@ -190,54 +205,41 @@ export class RegularLayoutFrame extends HTMLElement { return; } - const new_panel = event.detail; - let new_tab_panel = this._layout.getPanel(slot, new_panel); - if (!new_tab_panel) { - new_tab_panel = { - type: "tab-layout", - tabs: [slot], - selected: 0, - }; - } - - for (let i = 0; i < new_tab_panel.tabs.length; i++) { - if (i >= this._header.children.length) { - 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 = this._header.children[i] as RegularLayoutTab; - tab.populate(this._layout, new_tab_panel, i); - } - } - - const last_index = new_tab_panel.tabs.length; - for (let j = this._header.children.length - 1; j >= last_index; j--) { - this._header.removeChild(this._header.children[j]); + let tab_panel = this._layout.getPanel(slot, event.detail); + if (!tab_panel) { + tab_panel = { type: "tab-layout", tabs: [slot], selected: 0 }; } - this.drawTitles(attr, new_tab_panel.tabs); + // Each frame renders only its *own* tab. Frames in a stack overlap (see + // `create_css_grid_layout`); the tabs tile because every frame derives + // the same column template and places its tab in its own column. Only + // the selected frame shows its content; the rest keep just their tab. + const index = tab_panel.tabs.indexOf(slot); + const selected = (tab_panel.selected ?? 0) === index; + this.toggleAttribute("inactive", !selected); + this._default_tab.populate(this._layout, tab_panel, index); + this.drawTab(slot, index, tab_panel.tabs.length); }; /** - * Regenerates this frame's title stylesheet, mapping each tab (by its slot - * `name`) to its label. The label is rendered via the title's `::before` - * `content`, reading the slot's `--regular-layout---title` override - * variable with the slot name as the fallback - so a consumer can relabel a - * tab purely in CSS and the change applies reactively. + * Regenerates this frame's tab stylesheet: the shared titlebar grid template + * (so every frame in the stack aligns), the column this frame's tab occupies, + * and the tab label. The label renders via the title's `::before` `content`, + * reading the slot's `--regular-layout---title` override variable with + * the slot name as the fallback - so a consumer can relabel a tab purely in + * CSS and the change applies reactively. * - * @param attr - The child name attribute (`CHILD_ATTRIBUTE_NAME`). - * @param tabs - The slot names rendered in this frame's titlebar. + * @param slot - This frame's panel name. + * @param index - This frame's column (zero-based) within the stack. + * @param count - The number of tabs in the stack. */ - private drawTitles = (attr: string, tabs: string[]) => { - const css = tabs - .map( - (slot) => - `regular-layout-tab[${attr}="${slot}"] [part~="title"]::before{content:var(${title_variable(slot)}, ${css_string(slot)})}`, - ) - .join("\n"); - - this._title_sheet.replaceSync(css); + private drawTab = (slot: string, index: number, count: number) => { + this._tab_sheet.replaceSync( + [ + `.tabs{grid-template-columns:repeat(${count},var(--rl-tab-width,1fr))}`, + `.tabs slot[name="tab"]{grid-column:${index + 1}}`, + `[part~="title"]::before{content:var(${title_variable(slot)}, ${css_string(slot)})}`, + ].join("\n"), + ); }; } diff --git a/tests/integration/adopted-stylesheets.spec.ts b/tests/integration/adopted-stylesheets.spec.ts index 20c717f..b1c6e89 100644 --- a/tests/integration/adopted-stylesheets.spec.ts +++ b/tests/integration/adopted-stylesheets.spec.ts @@ -66,12 +66,20 @@ test("should generate correct CSS for SINGLE_AAA layout", async ({ page }) => { test("should generate correct CSS for SINGLE_TABS layout", async ({ page }) => { await setupLayout(page, LAYOUTS.SINGLE_TABS); const css = await getAdoptedStyleSheetCSS(page); + // Stacked tabs overlap in one cell; only the selected tab is lifted. expect(normalizeCSS(css)).toContain( - normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'), + normalizeCSS( + ':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;z-index:1;}', + ), ); - expect(normalizeCSS(css)).not.toContain('name="BBB"'); - expect(normalizeCSS(css)).not.toContain('name="CCC"'); + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="BBB"]){display:flex;grid-area:1 / 1;}'), + ); + + expect(normalizeCSS(css)).toContain( + normalizeCSS(':host::slotted([name="CCC"]){display:flex;grid-area:1 / 1;}'), + ); }); test("should generate correct CSS for TWO_HORIZONTAL layout", async ({ @@ -289,7 +297,8 @@ test("should generate correct CSS for TWO_HORIZONTAL_WITH_TABS", async ({ const css = await getAdoptedStyleSheetCSS(page); expect(normalizeCSS(css)).toContain('name="AAA"'); expect(normalizeCSS(css)).toContain('name="CCC"'); - expect(normalizeCSS(css)).not.toContain('name="BBB"'); + // BBB is stacked with AAA; it is now placed (overlapped), not omitted. + expect(normalizeCSS(css)).toContain('name="BBB"'); expect(normalizeCSS(css)).toContain("grid-template-columns:50fr 50fr"); }); diff --git a/tests/integration/real-coordinates.spec.ts b/tests/integration/real-coordinates.spec.ts index 451d8bd..5dbd117 100644 --- a/tests/integration/real-coordinates.spec.ts +++ b/tests/integration/real-coordinates.spec.ts @@ -45,11 +45,14 @@ test("realCoordinates matches getBoundingClientRect for 3 horizontal children", if (!hit || hit.slot !== name) continue; const rect = layout.realCoordinates(hit.view_window); results[name] = { + // The frame now fills its grid cell (theme chrome lives on + // `::part(container)`), so `realCoordinates` matches its box + // directly with no margin compensation. real: { - x: rect.x + 3, - y: rect.y + 27, - width: rect.width - 6, - height: rect.height - 30, + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, }, actual: { x: panelRect.x, diff --git a/tests/integration/tabs.spec.ts b/tests/integration/tabs.spec.ts index bf1825c..72fab8c 100644 --- a/tests/integration/tabs.spec.ts +++ b/tests/integration/tabs.spec.ts @@ -10,62 +10,59 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import { expect, test } from "../helpers/coverage.ts"; +import type { Page } from "@playwright/test"; import type { Layout } from "../../dist/index.js"; import { LAYOUTS } from "../helpers/fixtures.ts"; +// Each frame renders only its *own* tab, and the frames in a stack overlap; the +// tabs tile because every frame derives the same titlebar grid. The bundled +// example themes target `regular-layout.lorax` and assume the old flex/multi-tab +// titlebar, so these tests drop the theme class to exercise the default chrome. +const center = (page: Page, name: string) => + page.evaluate((name) => { + const slot = document + .querySelector(`regular-layout-frame[name="${name}"]`) + ?.shadowRoot?.querySelector('slot[name="tab"]') as HTMLElement | null; + + if (!slot) return null; + const r = slot.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }, name); + +const activeLabel = (page: Page, name: string) => + page.evaluate((name) => { + const title = document + .querySelector(`regular-layout-frame[name="${name}"]`) + ?.shadowRoot?.querySelector('[part~="active-tab"] [part~="title"]'); + return title ? getComputedStyle(title, "::before").content : undefined; + }, name); + test("should switch between tabs by clicking", async ({ page }) => { await page.goto("/examples/index.html"); await page.waitForSelector("regular-layout"); await page.evaluate(async (layout) => { - const layoutElement = document.querySelector("regular-layout"); - await layoutElement?.restore(layout as Layout); + const root = document.querySelector("regular-layout"); + if (root) root.className = ""; + await root?.restore(layout as Layout); }, LAYOUTS.SINGLE_TABS_WITH_SELECTED); - const getSelectedTab = async (slot: string) => { - return await page.evaluate((slot) => { - const frame = document.querySelector( - `regular-layout-frame[name=${slot}]`, - ); - const title = frame?.shadowRoot?.querySelector( - '[part~="active-tab"] [part~="title"]', - ); - // The label renders via the title's `::before` `content`. - return title ? getComputedStyle(title, "::before").content : undefined; - }, slot); - }; - - const selectedBefore = await getSelectedTab("AAA"); - expect(selectedBefore).toBe('"AAA"'); - const frameBounds = await page.evaluate(() => { - 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; - const rect = secondTab.getBoundingClientRect(); - return { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - }; - }); - - expect(frameBounds).not.toBeNull(); - if (frameBounds) { - await page.mouse.click(frameBounds.x, frameBounds.y); - } + // AAA is selected, so its own tab is the active one. + expect(await activeLabel(page, "AAA")).toBe('"AAA"'); - // Since tab selection has happened, the visible titlebar is now "BBB"'s - const selectedAfter = await getSelectedTab("BBB"); - expect(selectedAfter).toBe('"BBB"'); - const layoutState = await page.evaluate(() => { - const layout = document.querySelector("regular-layout"); - return layout?.save(); + // Click BBB's own tab (it tiles into column 2, clickable through the + // overlapping frames). + const bbb = await center(page, "BBB"); + expect(bbb).not.toBeNull(); + if (bbb) await page.mouse.click(bbb.x, bbb.y); + await page.waitForFunction(() => { + const l = document.querySelector("regular-layout"); + return (l?.save() as { selected?: number } | undefined)?.selected === 1; }); - expect(layoutState).toMatchObject({ - type: "tab-layout", - tabs: ["AAA", "BBB", "CCC"], - selected: 1, - }); + expect(await activeLabel(page, "BBB")).toBe('"BBB"'); + expect( + await page.evaluate(() => document.querySelector("regular-layout")?.save()), + ).toMatchObject({ type: "tab-layout", tabs: ["AAA", "BBB", "CCC"], selected: 1 }); }); test("should move a panel by dragging a selected tab", async ({ page }) => { @@ -134,6 +131,7 @@ test("should move a panel by dragging a deselected tab", async ({ page }) => { await page.waitForSelector("regular-layout"); await page.evaluate(async (layout) => { const layoutElement = document.querySelector("regular-layout"); + if (layoutElement) layoutElement.className = ""; await layoutElement?.restore(layout as Layout); }, LAYOUTS.TWO_HORIZONTAL_WITH_TABS); @@ -158,32 +156,20 @@ test("should move a panel by dragging a deselected tab", async ({ page }) => { ], }); - const dragCoords = await page.evaluate(() => { - 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( - (tab) => !tab.getAttribute("part")?.includes("active-tab"), - ) as HTMLElement; - - if (!inactiveTab) return null; - const tabRect = inactiveTab.getBoundingClientRect(); + // BBB is the deselected tab; in the own-tab model it lives in BBB's own + // (overlapping) frame. Dragging it must move BBB, not the front-most AAA. + const from = await center(page, "BBB"); + const to = await page.evaluate(() => { const layout = document.querySelector("regular-layout"); - const layoutRect = layout?.getBoundingClientRect(); - if (!layoutRect) return null; - return { - fromX: tabRect.left + tabRect.width / 2, - fromY: tabRect.top + tabRect.height / 2, - toX: layoutRect.right - 50, - toY: layoutRect.top + layoutRect.height / 2, - }; + const r = layout?.getBoundingClientRect(); + return r ? { x: r.right - 50, y: r.top + r.height / 2 } : { x: 0, y: 0 }; }); - expect(dragCoords).not.toBeNull(); - if (dragCoords) { - await page.mouse.move(dragCoords.fromX, dragCoords.fromY); + expect(from).not.toBeNull(); + if (from) { + await page.mouse.move(from.x, from.y); await page.mouse.down(); - await page.mouse.move(dragCoords.toX, dragCoords.toY); + await page.mouse.move(to.x, to.y); await page.mouse.up(); } @@ -221,7 +207,8 @@ test("should label tabs from the `----title` CSS variable, falling back to const labels = await page.evaluate(async (layout) => { // AAA's label is overridden via CSS; BBB has no override and falls back - // to its slot name. Labels render via the title's `::before` `content`. + // to its slot name. Each frame renders only its own tab, so AAA's label + // lives in AAA's frame and BBB's in BBB's frame. const layoutElement = document.createElement("regular-layout"); layoutElement.style.setProperty("--regular-layout-AAA--title", '"Alpha"'); const aaa = document.createElement("regular-layout-frame"); @@ -233,11 +220,12 @@ test("should label tabs from the `----title` CSS variable, falling back to await layoutElement.restore(layout as Layout); - const tabs = Array.from( - aaa.shadowRoot?.querySelectorAll('[part~="title"]') ?? [], - ); + const label = (frame: Element) => { + const title = frame.shadowRoot?.querySelector('[part~="title"]'); + return title ? getComputedStyle(title, "::before").content : undefined; + }; - return tabs.map((tab) => getComputedStyle(tab, "::before").content); + return [label(aaa), label(bbb)]; }, LAYOUTS.SINGLE_TABS as Layout); // `content` resolves to the (quoted) CSS string. @@ -253,6 +241,7 @@ test("should fire `regular-layout-select` when a tab is selected", async ({ await page.evaluate(async (layout) => { const layoutElement = document.querySelector("regular-layout"); + if (layoutElement) layoutElement.className = ""; const selected: string[] = []; (window as unknown as { selected: string[] }).selected = selected; layoutElement?.addEventListener("regular-layout-select", (event) => { @@ -262,39 +251,17 @@ test("should fire `regular-layout-select` when a tab is selected", async ({ await layoutElement?.restore(layout as Layout); }, LAYOUTS.SINGLE_TABS_WITH_SELECTED); - // Locate a tab within a specific frame's titlebar. Only the selected - // panel's frame is visible (others are `display:none`), so the re-click - // must target whichever frame is currently front-most. - const tabCenter = (frameName: string, index: number) => - page.evaluate( - ({ frameName, index }) => { - const frame = document.querySelector( - `regular-layout-frame[name="${frameName}"]`, - ); - - const tab = frame?.shadowRoot?.querySelectorAll('[part~="tab"]')[ - index - ] as HTMLElement | undefined; - - if (!tab) return null; - const rect = tab.getBoundingClientRect(); - return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; - }, - { frameName, index }, - ); - - // Click background tab BBB (index 1) in the visible AAA frame - selection - // changes and the event fires. - const bbb = await tabCenter("AAA", 1); + // Click BBB's own tab (it tiles into its column, clickable through the + // overlapping frames) - selection changes and the event fires. + const bbb = await center(page, "BBB"); expect(bbb).not.toBeNull(); if (bbb) await page.mouse.click(bbb.x, bbb.y); await page.waitForFunction( () => (window as unknown as { selected: string[] }).selected.length >= 1, ); - // Re-click the now-active BBB tab in the now-visible BBB frame - - // re-selection still fires. - const bbbAgain = await tabCenter("BBB", 1); + // Re-click BBB's now-active tab - re-selection still fires. + const bbbAgain = await center(page, "BBB"); expect(bbbAgain).not.toBeNull(); if (bbbAgain) await page.mouse.click(bbbAgain.x, bbbAgain.y); await page.waitForFunction( diff --git a/tests/unit/css_grid_layout.spec.ts b/tests/unit/css_grid_layout.spec.ts index 588f2de..b7d7c09 100644 --- a/tests/unit/css_grid_layout.spec.ts +++ b/tests/unit/css_grid_layout.spec.ts @@ -391,3 +391,25 @@ test("Deeply alternating split", () => { `.trim(), ); }); + +test("stacked tabs overlap in one cell, lifting only the selected tab", () => { + const stack: Layout = { + type: "tab-layout", + tabs: ["AAA", "BBB", "CCC"], + selected: 0, + }; + + expect( + create_css_grid_layout(stack, undefined, { + ...DEFAULT_PHYSICS, + SHOULD_ROUND: true, + }), + ).toEqual( + ` +:host ::slotted(*){display:none}:host{display:grid;grid-template-rows:100%;grid-template-columns:100%} +:host ::slotted([name="AAA"]){display:flex;grid-column:1;grid-row:1;z-index:1} +:host ::slotted([name="BBB"]){display:flex;grid-column:1;grid-row:1} +:host ::slotted([name="CCC"]){display:flex;grid-column:1;grid-row:1} + `.trim(), + ); +}); diff --git a/tests/unit/css_grid_layout_partial.spec.ts b/tests/unit/css_grid_layout_partial.spec.ts index 037b0ea..e6c6d89 100644 --- a/tests/unit/css_grid_layout_partial.spec.ts +++ b/tests/unit/css_grid_layout_partial.spec.ts @@ -75,7 +75,7 @@ test("Deeply alternating split with grid-based overlay", () => { :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]){z-index:2} :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} diff --git a/themes/borland.css b/themes/borland.css index a122a50..22fa70a 100644 --- a/themes/borland.css +++ b/themes/borland.css @@ -23,12 +23,15 @@ regular-layout.borland regular-layout-frame { position: relative; box-sizing: border-box; margin: 4px; - background: #0000aa; - border: 1px solid #00aaaa; - box-shadow: 1px 1px 0 #000000; } +/* Panel chrome lives on the content (only the selected frame shows it), so the + overlapping frame box stays transparent and every stacked tab is visible. */ regular-layout.borland regular-layout-frame::part(container) { + background: #0000aa; + border: 1px solid #00aaaa; + box-shadow: 1px 1px 0 #000000; + box-sizing: border-box; padding: 4px 6px; color: #ffff55; } @@ -59,7 +62,6 @@ regular-layout.borland regular-layout-frame::part(titlebar) { align-items: stretch; padding-right: 0; height: 22px; - background: #0000aa; } regular-layout.borland regular-layout-frame::part(tab) { diff --git a/themes/chicago.css b/themes/chicago.css index 84a66a2..22c330a 100644 --- a/themes/chicago.css +++ b/themes/chicago.css @@ -23,13 +23,16 @@ regular-layout.chicago regular-layout-frame { position: relative; box-sizing: border-box; margin: 12px; +} + +/* Panel chrome lives on the content (only the selected frame shows it), so the + overlapping frame box stays transparent and every stacked tab is visible. */ +regular-layout.chicago regular-layout-frame::part(container) { background: #c0c0c0; border-width: 2px; border-color: #ffffff #808080 #808080 #ffffff; border-style: solid; -} - -regular-layout.chicago regular-layout-frame::part(container) { + box-sizing: border-box; padding: 6px; } @@ -58,8 +61,8 @@ regular-layout.chicago regular-layout-frame::part(titlebar) { } regular-layout.chicago regular-layout-frame::part(tab) { + box-sizing: border-box; display: flex; - flex: 1 1 150px; align-items: center; padding: 0 3px 0 8px; cursor: pointer; @@ -69,6 +72,11 @@ regular-layout.chicago regular-layout-frame::part(tab) { font-family: "Tahoma", "Arial", sans-serif; font-weight: bold; font-size: 11px; + /* Raised bevel - the frame box no longer carries the border in the + overlap model, so each tab frames itself. */ + border-width: 2px; + border-color: #ffffff #808080 #808080 #ffffff; + border-style: solid; } regular-layout.chicago regular-layout-frame::part(active-tab) { diff --git a/themes/fluxbox.css b/themes/fluxbox.css index e82b675..cd28bf1 100644 --- a/themes/fluxbox.css +++ b/themes/fluxbox.css @@ -21,13 +21,16 @@ 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); } +/* Panel chrome lives on the content (only the selected frame shows it), so the + overlapping frame box stays transparent and every stacked tab is visible. */ regular-layout.fluxbox regular-layout-frame::part(container) { + box-sizing: border-box; padding: 6px; + 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) { diff --git a/themes/gibson.css b/themes/gibson.css index cfaa744..1d294e0 100644 --- a/themes/gibson.css +++ b/themes/gibson.css @@ -20,12 +20,9 @@ regular-layout.gibson { /* 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); color: white; text-shadow: 0 0 5px #fff, @@ -33,8 +30,13 @@ regular-layout.gibson regular-layout-frame { 0 0 20px #fff; } +/* Panel chrome lives on the content (only the selected frame shows it), so the + overlapping frame box stays transparent and every stacked tab is visible. */ regular-layout.gibson regular-layout-frame::part(container) { padding: 6px; + background: rgba(10, 10, 26, 0.85); + backdrop-filter: blur(4px); + box-sizing: border-box; } regular-layout.gibson regular-layout-frame::part(active-close) { @@ -70,19 +72,11 @@ regular-layout.gibson regular-layout-frame::part(close) { } 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; @@ -131,6 +125,7 @@ regular-layout.gibson regular-layout-frame.overlay { inset 0 0 20px rgba(255, 0, 255, 0.2); margin: 0; transition: + margin 0.075s ease-out, top 0.075s ease-out, height 0.075s ease-out, width 0.075s ease-out, @@ -138,18 +133,9 @@ regular-layout.gibson regular-layout-frame.overlay { } 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; + /* Square top so the tab strip sits flush; keep the asymmetric bottom. */ + border-radius: 0 0 12px 0; } /* Cyberpunk color variations */ diff --git a/themes/hotdog.css b/themes/hotdog.css index 596efea..987c31b 100644 --- a/themes/hotdog.css +++ b/themes/hotdog.css @@ -21,13 +21,16 @@ regular-layout.hotdog { /* Frame */ regular-layout.hotdog regular-layout-frame { margin: 12px; +} + +/* Panel chrome lives on the content (only the selected frame shows it), so the + overlapping frame box stays transparent and every stacked tab is visible. */ +regular-layout.hotdog regular-layout-frame::part(container) { background: #ffff00; border-width: 2px; border-color: #ff6b6b #8b0000 #8b0000 #ff6b6b; border-style: solid; -} - -regular-layout.hotdog regular-layout-frame::part(container) { + box-sizing: border-box; padding: 6px; } @@ -57,8 +60,8 @@ regular-layout.hotdog regular-layout-frame::part(titlebar) { } regular-layout.hotdog regular-layout-frame::part(tab) { + box-sizing: border-box; display: flex; - flex: 1 1 150px; align-items: center; padding: 0 3px 0 8px; cursor: pointer; @@ -68,6 +71,11 @@ regular-layout.hotdog regular-layout-frame::part(tab) { font-family: "Tahoma", "Arial", sans-serif; font-weight: bold; font-size: 11px; + /* Raised bevel - the frame box no longer carries the border in the + overlap model, so each tab frames itself. */ + border-width: 2px; + border-color: #ff6b6b #8b0000 #8b0000 #ff6b6b; + border-style: solid; } regular-layout.hotdog regular-layout-frame::part(active-tab) { @@ -87,6 +95,7 @@ regular-layout.hotdog:has(.overlay) > .overlay { regular-layout.hotdog regular-layout-frame.overlay { margin: 0; transition: + margin 0.1 ease-in-out, top 0.1s ease-in-out, height 0.1s ease-in-out, width 0.1s ease-in-out, diff --git a/themes/lorax.css b/themes/lorax.css index be0318f..63fec80 100644 --- a/themes/lorax.css +++ b/themes/lorax.css @@ -10,50 +10,62 @@ * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ */ +/* + * In the own-tab model, the frames of a stack overlap in one cell: each frame + * renders only its own tab (the engine tiles them into the shared titlebar) and + * only the selected frame shows its content. So this theme styles appearance, + * not layout - panel chrome lives on `::part(container)` (shown only for the + * selected frame) rather than the overlapping frame box, and the titlebar strip + * is transparent so every stacked frame's tab shows through. + */ + 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 6px 6px 6px; - border: 1px solid #666; - box-shadow: 0px 6px 6px -4px rgba(150, 150, 180); + --rl-tab-width: 200px; } +/* Panel content chrome (only the selected frame's container is rendered). */ regular-layout.lorax regular-layout-frame::part(container) { + box-sizing: border-box; + margin: 0 3px 3px 3px; padding: 6px; + /* Square top so the tab strip sits flush; rounded bottom. */ + border-radius: 0 6px 6px 6px; + border: 1px solid #666; + box-shadow: 0px 6px 6px -4px rgba(150, 150, 180); } +/* Transparent strip; the engine tiles the per-frame tabs across it. */ 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; - padding-right: 6px; + margin: 3px 6px 0 3px; + align-items: end; } regular-layout.lorax regular-layout-frame::part(tab) { - display: flex; - flex: 1 1 150px; + box-sizing: border-box; align-items: center; - text-align: center; + justify-content: center; font-size: 10px; padding: 0 6px; + margin-right: 2px; cursor: pointer; - max-width: 150px; - text-overflow: ellipsis; border: 1px solid #666; + border-bottom: none; border-radius: 6px 6px 0 0; opacity: 0.5; } +regular-layout.lorax regular-layout-frame::part(title) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + regular-layout.lorax regular-layout-frame::part(active-tab) { opacity: 1; } @@ -71,67 +83,52 @@ regular-layout.lorax regular-layout-frame::part(close):hover { 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: 3px; - box-shadow: none; transition: + maring 0.1s ease-in-out, 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), +/* Colors (per panel position) - the content area and its tab share a hue. */ +regular-layout.lorax :nth-child(8n + 1)::part(container), 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(container), 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(container), 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(container), 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(container), 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(container), 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(container), 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(container), regular-layout.lorax :nth-child(8n + 8)::part(tab) { background-color: #ffc6ffff; }