Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions src/layout/generate_grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface GridCell {
colEnd: number;
rowStart: number;
rowEnd: number;
zIndex?: number;
}

function dedupe_sort(physics: Physics, result: number[], pos: number) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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");
}

Expand Down Expand Up @@ -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}`,
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/layout/generate_overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}}`;
}
114 changes: 58 additions & 56 deletions src/regular-layout-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<div part="titlebar"></div>
<div part="titlebar"><div class="tabs"><slot name="tab"><regular-layout-tab></regular-layout-tab></slot></div></div>
<div part="container"><slot></slot></div>
`;

Expand Down Expand Up @@ -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<RegularLayoutTab, number> = new WeakMap();

/**
* Initializes this elements. Override this method and
Expand All @@ -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);
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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-<slot>--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-<slot>--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"),
);
};
}
17 changes: 13 additions & 4 deletions tests/integration/adopted-stylesheets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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");
});

Expand Down
11 changes: 7 additions & 4 deletions tests/integration/real-coordinates.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading