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;
}