Skip to content

Commit 59a7ed4

Browse files
authored
Merge pull request #15 from texodus/custom-tabs
Allow overriding `<regular-layout-tab>` entirely
2 parents 9414b34 + 3ec7c80 commit 59a7ed4

14 files changed

Lines changed: 291 additions & 257 deletions

src/layout/generate_grid.ts

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface GridCell {
1818
colEnd: number;
1919
rowStart: number;
2020
rowEnd: number;
21+
zIndex?: number;
2122
}
2223

2324
function dedupe_sort(physics: Physics, result: number[], pos: number) {
@@ -102,15 +103,26 @@ function build_cells(
102103
): GridCell[] {
103104
if (panel.type === "tab-layout") {
104105
const selected = panel.selected ?? 0;
105-
return [
106-
{
107-
child: panel.tabs[selected],
108-
colStart: find_track_index(physics, colPositions, colStart),
109-
colEnd: find_track_index(physics, colPositions, colEnd),
110-
rowStart: find_track_index(physics, rowPositions, rowStart),
111-
rowEnd: find_track_index(physics, rowPositions, rowEnd),
112-
},
113-
];
106+
const col_start = find_track_index(physics, colPositions, colStart);
107+
const col_end = find_track_index(physics, colPositions, colEnd);
108+
const row_start = find_track_index(physics, rowPositions, rowStart);
109+
const row_end = find_track_index(physics, rowPositions, rowEnd);
110+
111+
// Stacked tabs all occupy the same cell, overlapping. Only the selected
112+
// frame is lifted (`z-index:1`) so its content sits on top; the rest
113+
// self-hide their content and just contribute their tab. Keeping the
114+
// max frame `z-index` at 1 lets the drag overlay sit above any stack
115+
// with a small constant (2). A single-tab stack
116+
// emits one placement with no `z-index`, so non-stacked output is
117+
// unchanged.
118+
return panel.tabs.map((child, i) => ({
119+
child,
120+
colStart: col_start,
121+
colEnd: col_end,
122+
rowStart: row_start,
123+
rowEnd: row_end,
124+
zIndex: panel.tabs.length > 1 && i === selected ? 1 : undefined,
125+
}));
114126
}
115127

116128
const { children, sizes, orientation } = panel;
@@ -162,8 +174,11 @@ const child_template = (
162174
slot: string,
163175
rowPart: string,
164176
colPart: string,
177+
zIndex?: number,
165178
) =>
166-
`:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:flex;grid-column:${colPart};grid-row:${rowPart}}`;
179+
`:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:flex;grid-column:${colPart};grid-row:${rowPart}${
180+
zIndex === undefined ? "" : `;z-index:${zIndex}`
181+
}}`;
167182

168183
/**
169184
* Generates CSS Grid styles to render a layout tree.
@@ -202,9 +217,18 @@ export function create_css_grid_layout(
202217
): string {
203218
if (layout.type === "tab-layout") {
204219
const selected = layout.selected ?? 0;
220+
const stacked = layout.tabs.length > 1;
205221
return [
206222
host_template("100%", "100%"),
207-
child_template(physics, layout.tabs[selected], "1", "1"),
223+
...layout.tabs.map((tab, i) =>
224+
child_template(
225+
physics,
226+
tab,
227+
"1",
228+
"1",
229+
stacked && i === selected ? 1 : undefined,
230+
),
231+
),
208232
].join("\n");
209233
}
210234

@@ -253,11 +277,13 @@ export function create_css_grid_layout(
253277
for (const cell of cells) {
254278
const colPart = formatGridLine(cell.colStart, cell.colEnd);
255279
const rowPart = formatGridLine(cell.rowStart, cell.rowEnd);
256-
css.push(child_template(physics, cell.child, rowPart, colPart));
280+
css.push(
281+
child_template(physics, cell.child, rowPart, colPart, cell.zIndex),
282+
);
257283
if (cell.child === overlay?.[1]) {
258284
css.push(child_template(physics, overlay[0], rowPart, colPart));
259285
css.push(
260-
`:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}=${overlay[0]}]){z-index:1}`,
286+
`:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}=${overlay[0]}]){z-index:2}`,
261287
);
262288
}
263289
}

src/layout/generate_overlay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,6 @@ export function updateOverlaySheet(
6969
margin,
7070
);
7171

72-
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;`;
72+
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;`;
7373
return `::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){${css}}`;
7474
}

src/regular-layout-frame.ts

Lines changed: 58 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,19 @@ import type { RegularLayout } from "./regular-layout.ts";
1515
import type { RegularLayoutTab } from "./regular-layout-tab.ts";
1616

1717
const CSS = `
18-
:host{box-sizing:border-box;flex-direction:column}
19-
:host::part(titlebar){display:flex;height:24px;user-select:none;overflow:hidden}
20-
:host::part(container){flex:1 1 auto}
21-
:host::part(title){flex:1 1 auto;pointer-events:none}
22-
:host::part(close){align-self:stretch}
23-
:host::slotted{flex:1 1 auto;}
24-
:host regular-layout-tab{width:0px;}
18+
:host{box-sizing:border-box;flex-direction:column;pointer-events:none}
19+
[part~="titlebar"]{height:24px;user-select:none;overflow:hidden}
20+
[part~="container"]{flex:1 1 auto;pointer-events:auto}
21+
[part~="title"]{flex:1 1 auto;pointer-events:none}
22+
[part~="close"]{align-self:stretch}
23+
:host([inactive]) [part~="container"]{display:none}
24+
.tabs{display:grid;width:100%;height:100%}
25+
.tabs slot[name="tab"]{display:grid;grid-row:1;pointer-events:auto}
26+
.tabs regular-layout-tab{display:flex;overflow:hidden}
2527
`;
2628

2729
const HTML_TEMPLATE = `
28-
<div part="titlebar"></div>
30+
<div part="titlebar"><div class="tabs"><slot name="tab"><regular-layout-tab></regular-layout-tab></slot></div></div>
2931
<div part="container"><slot></slot></div>
3032
`;
3133

@@ -82,11 +84,11 @@ const title_variable = (slot: string): string =>
8284
export class RegularLayoutFrame extends HTMLElement {
8385
private _shadowRoot!: ShadowRoot;
8486
private _container_sheet!: CSSStyleSheet;
85-
private _title_sheet!: CSSStyleSheet;
87+
private _tab_sheet!: CSSStyleSheet;
8688
private _layout!: RegularLayout;
8789
private _header!: HTMLElement;
90+
private _default_tab!: RegularLayoutTab;
8891
private _drag: DragState | null = null;
89-
private _tab_to_index_map: WeakMap<RegularLayoutTab, number> = new WeakMap();
9092

9193
/**
9294
* Initializes this elements. Override this method and
@@ -96,15 +98,20 @@ export class RegularLayoutFrame extends HTMLElement {
9698
connectedCallback() {
9799
this._container_sheet ??= new CSSStyleSheet();
98100
this._container_sheet.replaceSync(CSS);
99-
this._title_sheet ??= new CSSStyleSheet();
101+
this._tab_sheet ??= new CSSStyleSheet();
100102
this._shadowRoot ??= this.attachShadow({ mode: "open" });
101103
this._shadowRoot.adoptedStyleSheets = [
102104
this._container_sheet,
103-
this._title_sheet,
105+
this._tab_sheet,
104106
];
105107
this._shadowRoot.innerHTML = HTML_TEMPLATE;
106108
this._layout = this.parentElement as RegularLayout;
107109
this._header = this._shadowRoot.children[0] as HTMLElement;
110+
// The built-in tab is the `slot="tab"` fallback; a consumer-supplied
111+
// `slot="tab"` child replaces it.
112+
this._default_tab = this._header.querySelector(
113+
"regular-layout-tab",
114+
) as RegularLayoutTab;
108115
this._header.addEventListener("pointerdown", this.onPointerDown);
109116
this.addEventListener("pointermove", this.onPointerMove);
110117
this.addEventListener("pointerup", this.onPointerUp);
@@ -140,9 +147,17 @@ export class RegularLayoutFrame extends HTMLElement {
140147

141148
const elem = event.target as RegularLayoutTab;
142149
if (elem.part.contains("tab")) {
150+
const slot = this.getAttribute(
151+
this._layout.savePhysics().CHILD_ATTRIBUTE_NAME,
152+
);
153+
143154
const path = this._layout.calculateIntersect(event);
144-
if (path) {
145-
this._drag = { path };
155+
if (path && slot) {
156+
// Drag *this frame's* panel, not whichever panel is front-most at
157+
// the pointer (which is what `calculateIntersect` resolves to in a
158+
// stack). The overlapping stack shares geometry, so only the slot
159+
// needs overriding.
160+
this._drag = { path: { ...path, slot } };
146161
this.setPointerCapture(event.pointerId);
147162
event.preventDefault();
148163
} else {
@@ -190,54 +205,41 @@ export class RegularLayoutFrame extends HTMLElement {
190205
return;
191206
}
192207

193-
const new_panel = event.detail;
194-
let new_tab_panel = this._layout.getPanel(slot, new_panel);
195-
if (!new_tab_panel) {
196-
new_tab_panel = {
197-
type: "tab-layout",
198-
tabs: [slot],
199-
selected: 0,
200-
};
201-
}
202-
203-
for (let i = 0; i < new_tab_panel.tabs.length; i++) {
204-
if (i >= this._header.children.length) {
205-
const new_tab = document.createElement("regular-layout-tab");
206-
new_tab.populate(this._layout, new_tab_panel, i);
207-
this._header.appendChild(new_tab);
208-
this._tab_to_index_map.set(new_tab, i);
209-
} else {
210-
const tab = this._header.children[i] as RegularLayoutTab;
211-
tab.populate(this._layout, new_tab_panel, i);
212-
}
213-
}
214-
215-
const last_index = new_tab_panel.tabs.length;
216-
for (let j = this._header.children.length - 1; j >= last_index; j--) {
217-
this._header.removeChild(this._header.children[j]);
208+
let tab_panel = this._layout.getPanel(slot, event.detail);
209+
if (!tab_panel) {
210+
tab_panel = { type: "tab-layout", tabs: [slot], selected: 0 };
218211
}
219212

220-
this.drawTitles(attr, new_tab_panel.tabs);
213+
// Each frame renders only its *own* tab. Frames in a stack overlap (see
214+
// `create_css_grid_layout`); the tabs tile because every frame derives
215+
// the same column template and places its tab in its own column. Only
216+
// the selected frame shows its content; the rest keep just their tab.
217+
const index = tab_panel.tabs.indexOf(slot);
218+
const selected = (tab_panel.selected ?? 0) === index;
219+
this.toggleAttribute("inactive", !selected);
220+
this._default_tab.populate(this._layout, tab_panel, index);
221+
this.drawTab(slot, index, tab_panel.tabs.length);
221222
};
222223

223224
/**
224-
* Regenerates this frame's title stylesheet, mapping each tab (by its slot
225-
* `name`) to its label. The label is rendered via the title's `::before`
226-
* `content`, reading the slot's `--regular-layout-<slot>--title` override
227-
* variable with the slot name as the fallback - so a consumer can relabel a
228-
* tab purely in CSS and the change applies reactively.
225+
* Regenerates this frame's tab stylesheet: the shared titlebar grid template
226+
* (so every frame in the stack aligns), the column this frame's tab occupies,
227+
* and the tab label. The label renders via the title's `::before` `content`,
228+
* reading the slot's `--regular-layout-<slot>--title` override variable with
229+
* the slot name as the fallback - so a consumer can relabel a tab purely in
230+
* CSS and the change applies reactively.
229231
*
230-
* @param attr - The child name attribute (`CHILD_ATTRIBUTE_NAME`).
231-
* @param tabs - The slot names rendered in this frame's titlebar.
232+
* @param slot - This frame's panel name.
233+
* @param index - This frame's column (zero-based) within the stack.
234+
* @param count - The number of tabs in the stack.
232235
*/
233-
private drawTitles = (attr: string, tabs: string[]) => {
234-
const css = tabs
235-
.map(
236-
(slot) =>
237-
`regular-layout-tab[${attr}="${slot}"] [part~="title"]::before{content:var(${title_variable(slot)}, ${css_string(slot)})}`,
238-
)
239-
.join("\n");
240-
241-
this._title_sheet.replaceSync(css);
236+
private drawTab = (slot: string, index: number, count: number) => {
237+
this._tab_sheet.replaceSync(
238+
[
239+
`.tabs{grid-template-columns:repeat(${count},var(--rl-tab-width,1fr))}`,
240+
`.tabs slot[name="tab"]{grid-column:${index + 1}}`,
241+
`[part~="title"]::before{content:var(${title_variable(slot)}, ${css_string(slot)})}`,
242+
].join("\n"),
243+
);
242244
};
243245
}

tests/integration/adopted-stylesheets.spec.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,20 @@ test("should generate correct CSS for SINGLE_AAA layout", async ({ page }) => {
6666
test("should generate correct CSS for SINGLE_TABS layout", async ({ page }) => {
6767
await setupLayout(page, LAYOUTS.SINGLE_TABS);
6868
const css = await getAdoptedStyleSheetCSS(page);
69+
// Stacked tabs overlap in one cell; only the selected tab is lifted.
6970
expect(normalizeCSS(css)).toContain(
70-
normalizeCSS(':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;}'),
71+
normalizeCSS(
72+
':host::slotted([name="AAA"]){display:flex;grid-area:1 / 1;z-index:1;}',
73+
),
7174
);
7275

73-
expect(normalizeCSS(css)).not.toContain('name="BBB"');
74-
expect(normalizeCSS(css)).not.toContain('name="CCC"');
76+
expect(normalizeCSS(css)).toContain(
77+
normalizeCSS(':host::slotted([name="BBB"]){display:flex;grid-area:1 / 1;}'),
78+
);
79+
80+
expect(normalizeCSS(css)).toContain(
81+
normalizeCSS(':host::slotted([name="CCC"]){display:flex;grid-area:1 / 1;}'),
82+
);
7583
});
7684

7785
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 ({
289297
const css = await getAdoptedStyleSheetCSS(page);
290298
expect(normalizeCSS(css)).toContain('name="AAA"');
291299
expect(normalizeCSS(css)).toContain('name="CCC"');
292-
expect(normalizeCSS(css)).not.toContain('name="BBB"');
300+
// BBB is stacked with AAA; it is now placed (overlapped), not omitted.
301+
expect(normalizeCSS(css)).toContain('name="BBB"');
293302
expect(normalizeCSS(css)).toContain("grid-template-columns:50fr 50fr");
294303
});
295304

tests/integration/real-coordinates.spec.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,14 @@ test("realCoordinates matches getBoundingClientRect for 3 horizontal children",
4545
if (!hit || hit.slot !== name) continue;
4646
const rect = layout.realCoordinates(hit.view_window);
4747
results[name] = {
48+
// The frame now fills its grid cell (theme chrome lives on
49+
// `::part(container)`), so `realCoordinates` matches its box
50+
// directly with no margin compensation.
4851
real: {
49-
x: rect.x + 3,
50-
y: rect.y + 27,
51-
width: rect.width - 6,
52-
height: rect.height - 30,
52+
x: rect.x,
53+
y: rect.y,
54+
width: rect.width,
55+
height: rect.height,
5356
},
5457
actual: {
5558
x: panelRect.x,

0 commit comments

Comments
 (0)