diff --git a/src/extensions.ts b/src/extensions.ts index cdc1729..032cf89 100644 --- a/src/extensions.ts +++ b/src/extensions.ts @@ -65,6 +65,12 @@ declare global { options?: { signal: AbortSignal }, ): void; + addEventListener( + name: "regular-layout-select", + cb: (e: RegularLayoutSelectEvent) => void, + options?: { signal: AbortSignal }, + ): void; + removeEventListener( name: "regular-layout-update", cb: (e: RegularLayoutEvent) => void, @@ -79,8 +85,14 @@ declare global { name: "regular-layout-before-resize", cb: (e: RegularLayoutPresizeEvent) => void, ): void; + + removeEventListener( + name: "regular-layout-select", + cb: (e: RegularLayoutSelectEvent) => void, + ): void; } } export type RegularLayoutEvent = CustomEvent; export type RegularLayoutPresizeEvent = CustomEvent; +export type RegularLayoutSelectEvent = CustomEvent<{ name: string }>; diff --git a/src/layout/generate_grid.ts b/src/layout/generate_grid.ts index 134ff73..3e49721 100644 --- a/src/layout/generate_grid.ts +++ b/src/layout/generate_grid.ts @@ -264,3 +264,21 @@ export function create_css_grid_layout( return css.join("\n"); } + +/** + * Generates CSS Grid styles that render a single panel maximized to fill the + * entire layout, hiding all other slotted children. + * + * @param name - The name of the panel to maximize. + * @param physics - Instance constants (defaults to {@link DEFAULT_PHYSICS}). + * @returns CSS string showing only `name`, full-size. + */ +export function create_css_maximize_layout( + name: string, + physics: Physics = DEFAULT_PHYSICS, +): string { + return [ + host_template("100%", "100%"), + child_template(physics, name, "1", "1"), + ].join("\n"); +} diff --git a/src/model/overlay_controller.ts b/src/model/overlay_controller.ts index 340af6f..89af0e9 100644 --- a/src/model/overlay_controller.ts +++ b/src/model/overlay_controller.ts @@ -58,7 +58,6 @@ export class OverlayController { const [col, row, box, style] = host.relativeCoordinates(event, true); let drop_target = calculate_intersection(col, row, panel); - console.log(row, col, drop_target); if (drop_target) { drop_target = calculate_edge( col, diff --git a/src/regular-layout-frame.ts b/src/regular-layout-frame.ts index b3e8f5c..3373011 100644 --- a/src/regular-layout-frame.ts +++ b/src/regular-layout-frame.ts @@ -31,6 +31,20 @@ const HTML_TEMPLATE = ` type DragState = { moved?: boolean; path: LayoutPath }; +/** + * Escapes a string for safe use as a CSS `` token (the `content` + * fallback), handling backslashes, double-quotes, and newlines. + */ +const css_string = (value: string): string => + `"${value.replace(/[\\"\n]/g, (c) => (c === "\n" ? "\\A " : `\\${c}`))}"`; + +/** + * The per-slot CSS custom property a consumer sets to override a tab's label, + * e.g. `regular-layout { --regular-layout-my-panel--title: "My Panel"; }`. + */ +const title_variable = (slot: string): string => + `--regular-layout-${globalThis.CSS.escape(slot)}--title`; + /** * A custom element that represents a draggable panel within a * ``. @@ -47,8 +61,17 @@ type DragState = { moved?: boolean; path: LayoutPath }; * [named `slot`s](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_templates_and_slots) * for wholesale replacement of the underlying Shadow DOM. * + * The `name` attribute identifies the panel within the layout. The tab label + * defaults to `name`, but can be overridden with pure CSS by setting the + * `--regular-layout---title` custom property to a CSS string - the label + * is rendered via the tab title's `::before` `content`, so the override + * applies reactively with no JavaScript. + * * @example * ```html + * * * * @@ -59,6 +82,7 @@ type DragState = { moved?: boolean; path: LayoutPath }; export class RegularLayoutFrame extends HTMLElement { private _shadowRoot!: ShadowRoot; private _container_sheet!: CSSStyleSheet; + private _title_sheet!: CSSStyleSheet; private _layout!: RegularLayout; private _header!: HTMLElement; private _drag: DragState | null = null; @@ -72,8 +96,12 @@ export class RegularLayoutFrame extends HTMLElement { connectedCallback() { this._container_sheet ??= new CSSStyleSheet(); this._container_sheet.replaceSync(CSS); + this._title_sheet ??= new CSSStyleSheet(); this._shadowRoot ??= this.attachShadow({ mode: "open" }); - this._shadowRoot.adoptedStyleSheets = [this._container_sheet]; + this._shadowRoot.adoptedStyleSheets = [ + this._container_sheet, + this._title_sheet, + ]; this._shadowRoot.innerHTML = HTML_TEMPLATE; this._layout = this.parentElement as RegularLayout; this._header = this._shadowRoot.children[0] as HTMLElement; @@ -106,6 +134,10 @@ export class RegularLayoutFrame extends HTMLElement { } private onPointerDown = (event: PointerEvent): void => { + if (event.button !== 0) { + return; + } + const elem = event.target as RegularLayoutTab; if (elem.part.contains("tab")) { const path = this._layout.calculateIntersect(event); @@ -184,5 +216,28 @@ 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.drawTitles(attr, new_tab_panel.tabs); + }; + + /** + * 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. + * + * @param attr - The child name attribute (`CHILD_ATTRIBUTE_NAME`). + * @param tabs - The slot names rendered in this frame's titlebar. + */ + 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); }; } diff --git a/src/regular-layout-tab.ts b/src/regular-layout-tab.ts index 0d60ab8..eeed163 100644 --- a/src/regular-layout-tab.ts +++ b/src/regular-layout-tab.ts @@ -34,6 +34,14 @@ export class RegularLayoutTab extends HTMLElement { * @param index - The index of this tab within the tab panel. */ populate = (layout: RegularLayout, tab_panel: TabLayout, index: number) => { + // Stamp the slot onto the tab so its title can be styled by name (see + // `RegularLayoutFrame#drawTitles`). Tag-qualified selectors keep this + // distinct from the panel `name` on ``. + this.setAttribute( + layout.savePhysics().CHILD_ATTRIBUTE_NAME, + tab_panel.tabs[index], + ); + if (this._tab_panel) { const tab_changed = (index === tab_panel.selected) !== @@ -44,9 +52,6 @@ export class RegularLayoutTab extends HTMLElement { if (index_changed) { const selected = tab_panel.selected === index; - const slot = tab_panel.tabs[index]; - this.children[0].textContent = slot; - if (selected) { this.children[1].part.add("active-close"); this.part.add("active-tab"); @@ -56,7 +61,6 @@ export class RegularLayoutTab extends HTMLElement { } } } else { - const slot = tab_panel.tabs[index]; const selected = tab_panel.selected === index; const parts = selected ? "active-close close" : "close"; this.innerHTML = `
`; @@ -67,7 +71,6 @@ export class RegularLayoutTab extends HTMLElement { } this.addEventListener("pointerdown", this.onTabClick); - this.children[0].textContent = slot; this.children[1].addEventListener("pointerdown", this.onTabClose); } @@ -76,28 +79,23 @@ export class RegularLayoutTab extends HTMLElement { this._index = index; }; - private onTabClose = (_: Event) => { + private onTabClose = (event: Event) => { + if (event instanceof PointerEvent && event?.button !== 0) { + return; + } + if (this._tab_panel !== undefined && this._index !== undefined) { this._layout?.removePanel(this._tab_panel.tabs[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.tabs[this._index], - new_layout, - ); + private onTabClick = (event: PointerEvent) => { + if (event.button !== 0) { + return; + } - if (new_tab_panel && new_layout) { - new_tab_panel.selected = this._index; - this._layout?.restore(new_layout); - } + if (this._tab_panel !== undefined && this._index !== undefined) { + this._layout?.select(this._tab_panel.tabs[this._index]); } }; } diff --git a/src/regular-layout.ts b/src/regular-layout.ts index 9edc94b..932edbf 100644 --- a/src/regular-layout.ts +++ b/src/regular-layout.ts @@ -17,7 +17,10 @@ */ import { EMPTY_PANEL } from "./core/types.ts"; -import { create_css_grid_layout } from "./layout/generate_grid.ts"; +import { + create_css_grid_layout, + create_css_maximize_layout, +} from "./layout/generate_grid.ts"; import type { LayoutPath, Layout, @@ -106,6 +109,14 @@ export interface PointerEventCoordinates { * latter implementation would require synchronizing the light DOM * and shadow DOM slots/slotted children continuously. * + * Note that `::slotted()` only matches *directly* slotted nodes - it + * does not pierce a slotted ``. A consumer that wants to forward + * its own light-DOM content through `` therefore cannot + * place a bare `` as a layout child and expect it to be + * grid-positioned; instead, wrap the forwarding slot in a concrete + * `` (which *is* directly slotted and so + * matched by the generated `::slotted([name="X"])` rules). + * */ export class RegularLayout extends HTMLElement { private _shadowRoot: ShadowRoot; @@ -118,6 +129,7 @@ export class RegularLayout extends HTMLElement { private _physics: Physics; private _presizeQueue: PresizeQueue; private _overlayController: OverlayController; + private _maximized?: string; constructor() { super(); @@ -253,6 +265,36 @@ export class RegularLayout extends HTMLElement { await this.restore(remove_child(this._panel, name)); }; + /** + * Selects a panel by `name`, making it the front-most tab within its + * containing stack, then dispatches a `-select` event with + * `detail: { name }`. + * + * The event fires whenever a tab is selected - including re-selecting the + * already-active tab, or selecting the sole tab of a single-element stack - + * so consumers can always map a tab interaction to an "active panel". When + * the selection actually changes, a `-update` event is dispatched + * first (via {@link restore}). + * + * @param name - The name of the panel to select. + */ + select = async (name: string) => { + const layout = this.save(); + const tab_panel = this.getPanel(name, layout); + if (!tab_panel) { + return; + } + + const index = tab_panel.tabs.indexOf(name); + if (index !== -1 && tab_panel.selected !== index) { + tab_panel.selected = index; + await this.restore(layout); + } + + const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-select`; + this.dispatchEvent(new CustomEvent(event_name, { detail: { name } })); + }; + /** * Retrieves a panel by name from the layout tree. * @@ -286,6 +328,42 @@ export class RegularLayout extends HTMLElement { await this.restore(EMPTY_PANEL); }; + /** + * Maximizes a panel by `name`, rendering it full-size and hiding every + * other panel. This is transient display state - it is **not** persisted by + * {@link save}, and any subsequent {@link restore} resets the layout to the + * minimized (normal, multi-panel) view. + * + * Has no effect if `name` is not present in the current layout. + * + * @param name - The name of the panel to maximize. + */ + maximize = (name: string) => { + if (!this.getPanel(name)) { + return; + } + + this._maximized = name; + this._stylesheet.replaceSync( + create_css_maximize_layout(name, this._physics), + ); + }; + + /** + * Restores the normal multi-panel view after a {@link maximize}, without + * altering the layout tree. No-op if no panel is currently maximized. + */ + minimize = () => { + if (this._maximized === undefined) { + return; + } + + this._maximized = undefined; + this._stylesheet.replaceSync( + create_css_grid_layout(this._panel, undefined, this._physics), + ); + }; + /** * Restores the layout from a saved state synchronously, without * dispatching the `regular-layout-before-resize` event. @@ -293,6 +371,7 @@ export class RegularLayout extends HTMLElement { * @param layout - The layout tree to restore */ restoreSync = (layout: Layout, _is_flattened: boolean = false) => { + this._maximized = undefined; this._panel = !_is_flattened ? flatten(layout) : layout; const css = create_css_grid_layout(this._panel, undefined, this._physics); this._stylesheet.replaceSync(css); @@ -320,6 +399,7 @@ export class RegularLayout extends HTMLElement { restore = async (layout: Layout, _is_flattened: boolean = false) => { const panel = !_is_flattened ? flatten(layout) : layout; await this._presizeQueue.run(panel, () => { + this._maximized = undefined; this._panel = panel; const css = create_css_grid_layout(this._panel, undefined, this._physics); this._stylesheet.replaceSync(css); @@ -327,6 +407,7 @@ export class RegularLayout extends HTMLElement { const event = new CustomEvent(event_name, { detail: this._panel, }); + this.dispatchEvent(event); }); }; @@ -528,6 +609,10 @@ export class RegularLayout extends HTMLElement { }; private onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + if (!this._physics.GRID_DIVIDER_CHECK_TARGET || event.target === this) { const [col, row, rect] = this.relativeCoordinates(event); const size = this._physics.GRID_DIVIDER_SIZE; diff --git a/tests/integration/maximize.spec.ts b/tests/integration/maximize.spec.ts new file mode 100644 index 0000000..2c71122 --- /dev/null +++ b/tests/integration/maximize.spec.ts @@ -0,0 +1,83 @@ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░ +// ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░ +// ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░ +// ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ * 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 "../helpers/coverage.ts"; +import { setupLayout, saveLayout } from "../helpers/integration.ts"; +import { LAYOUTS } from "../helpers/fixtures.ts"; + +const panelDisplay = (page: import("@playwright/test").Page, name: string) => + page.evaluate((name) => { + const panel = document.querySelector(`[name="${name}"]`); + return panel ? getComputedStyle(panel).display : undefined; + }, name); + +test("should maximize a panel, hiding the others", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + await page.evaluate(() => { + document.querySelector("regular-layout")?.maximize("AAA"); + }); + + // The maximized panel fills the layout; the other is hidden. + expect(await panelDisplay(page, "AAA")).toBe("flex"); + expect(await panelDisplay(page, "BBB")).toBe("none"); +}); + +test("should minimize back to the normal multi-panel view", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + await page.evaluate(() => { + const layout = document.querySelector("regular-layout"); + layout?.maximize("AAA"); + layout?.minimize(); + }); + + expect(await panelDisplay(page, "AAA")).toBe("flex"); + expect(await panelDisplay(page, "BBB")).toBe("flex"); +}); + +test("should not persist maximize state in `save`", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + const before = await saveLayout(page); + + await page.evaluate(() => { + document.querySelector("regular-layout")?.maximize("AAA"); + }); + + // `save` ignores transient maximize state - the tree is unchanged. + expect(await saveLayout(page)).toEqual(before); +}); + +test("should reset to minimized on `restore`", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + await page.evaluate(async (layout) => { + const element = document.querySelector("regular-layout"); + element?.maximize("AAA"); + // Restoring (even the identical layout) always returns to minimized. + await element?.restore(layout as never); + }, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + expect(await panelDisplay(page, "AAA")).toBe("flex"); + expect(await panelDisplay(page, "BBB")).toBe("flex"); +}); + +test("should ignore `maximize` for an absent panel", async ({ page }) => { + await setupLayout(page, LAYOUTS.TWO_HORIZONTAL_EQUAL); + + await page.evaluate(() => { + document.querySelector("regular-layout")?.maximize("ZZZ"); + }); + + // Both panels remain visible; the no-op did not hide anything. + expect(await panelDisplay(page, "AAA")).toBe("flex"); + expect(await panelDisplay(page, "BBB")).toBe("flex"); +}); diff --git a/tests/integration/overlay-absolute.spec.ts b/tests/integration/overlay-absolute.spec.ts index ec39f9e..cfbfd84 100644 --- a/tests/integration/overlay-absolute.spec.ts +++ b/tests/integration/overlay-absolute.spec.ts @@ -39,7 +39,7 @@ test("should apply overlay class to dragged panel in absolute mode", async ({ ); // Verify AAA panel has overlay class - const panel = await page.locator("[name=AAA]"); + const panel = await page.locator("regular-layout-frame[name=AAA]"); expect(panel).toHaveClass("overlay"); }); @@ -108,9 +108,9 @@ test("should handle custom className in absolute mode", async ({ page }) => { { x, y }, ); - const panel = await page.locator("[name=AAA]"); + const panel = await page.locator("regular-layout-frame[name=AAA]"); expect(panel).toHaveClass("custom-drag-class"); - const panel2 = await page.locator("[name=AAA]"); + const panel2 = await page.locator("regular-layout-frame[name=AAA]"); expect(panel2).not.toHaveClass("overlay"); }); diff --git a/tests/integration/tabs.spec.ts b/tests/integration/tabs.spec.ts index ecfb833..bf1825c 100644 --- a/tests/integration/tabs.spec.ts +++ b/tests/integration/tabs.spec.ts @@ -26,15 +26,16 @@ test("should switch between tabs by clicking", async ({ page }) => { const frame = document.querySelector( `regular-layout-frame[name=${slot}]`, ); - const activeTab = frame?.shadowRoot?.querySelector( - '[part~="active-tab"]', + const title = frame?.shadowRoot?.querySelector( + '[part~="active-tab"] [part~="title"]', ); - return activeTab?.textContent; + // 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"); + 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"]'); @@ -54,7 +55,7 @@ test("should switch between tabs by clicking", async ({ page }) => { // Since tab selection has happened, the visible titlebar is now "BBB"'s const selectedAfter = await getSelectedTab("BBB"); - expect(selectedAfter).toBe("BBB"); + expect(selectedAfter).toBe('"BBB"'); const layoutState = await page.evaluate(() => { const layout = document.querySelector("regular-layout"); return layout?.save(); @@ -211,3 +212,98 @@ test("should move a panel by dragging a deselected tab", async ({ page }) => { ], }); }); + +test("should label tabs from the `----title` CSS variable, falling back to `name`", async ({ + page, +}) => { + await page.goto("/examples/index.html"); + await page.waitForSelector("regular-layout"); + + 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`. + const layoutElement = document.createElement("regular-layout"); + layoutElement.style.setProperty("--regular-layout-AAA--title", '"Alpha"'); + const aaa = document.createElement("regular-layout-frame"); + aaa.setAttribute("name", "AAA"); + const bbb = document.createElement("regular-layout-frame"); + bbb.setAttribute("name", "BBB"); + layoutElement.append(aaa, bbb); + document.body.append(layoutElement); + + await layoutElement.restore(layout as Layout); + + const tabs = Array.from( + aaa.shadowRoot?.querySelectorAll('[part~="title"]') ?? [], + ); + + return tabs.map((tab) => getComputedStyle(tab, "::before").content); + }, LAYOUTS.SINGLE_TABS as Layout); + + // `content` resolves to the (quoted) CSS string. + expect(labels[0]).toBe('"Alpha"'); + expect(labels[1]).toBe('"BBB"'); +}); + +test("should fire `regular-layout-select` when a tab is selected", async ({ + page, +}) => { + await page.goto("/examples/index.html"); + await page.waitForSelector("regular-layout"); + + await page.evaluate(async (layout) => { + const layoutElement = document.querySelector("regular-layout"); + const selected: string[] = []; + (window as unknown as { selected: string[] }).selected = selected; + layoutElement?.addEventListener("regular-layout-select", (event) => { + selected.push(event.detail.name); + }); + + 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); + 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); + expect(bbbAgain).not.toBeNull(); + if (bbbAgain) await page.mouse.click(bbbAgain.x, bbbAgain.y); + await page.waitForFunction( + () => (window as unknown as { selected: string[] }).selected.length >= 2, + ); + + const selected = await page.evaluate( + () => (window as unknown as { selected: string[] }).selected, + ); + + expect(selected).toEqual(["BBB", "BBB"]); +});