Skip to content

Commit 6ebf28d

Browse files
committed
Implement titles API, tab selection, regular-layout-select event
1 parent b3411fa commit 6ebf28d

9 files changed

Lines changed: 378 additions & 32 deletions

File tree

src/extensions.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ declare global {
6565
options?: { signal: AbortSignal },
6666
): void;
6767

68+
addEventListener(
69+
name: "regular-layout-select",
70+
cb: (e: RegularLayoutSelectEvent) => void,
71+
options?: { signal: AbortSignal },
72+
): void;
73+
6874
removeEventListener(
6975
name: "regular-layout-update",
7076
cb: (e: RegularLayoutEvent) => void,
@@ -79,8 +85,14 @@ declare global {
7985
name: "regular-layout-before-resize",
8086
cb: (e: RegularLayoutPresizeEvent) => void,
8187
): void;
88+
89+
removeEventListener(
90+
name: "regular-layout-select",
91+
cb: (e: RegularLayoutSelectEvent) => void,
92+
): void;
8293
}
8394
}
8495

8596
export type RegularLayoutEvent = CustomEvent<Layout>;
8697
export type RegularLayoutPresizeEvent = CustomEvent<PresizeDetail>;
98+
export type RegularLayoutSelectEvent = CustomEvent<{ name: string }>;

src/layout/generate_grid.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,21 @@ export function create_css_grid_layout(
264264

265265
return css.join("\n");
266266
}
267+
268+
/**
269+
* Generates CSS Grid styles that render a single panel maximized to fill the
270+
* entire layout, hiding all other slotted children.
271+
*
272+
* @param name - The name of the panel to maximize.
273+
* @param physics - Instance constants (defaults to {@link DEFAULT_PHYSICS}).
274+
* @returns CSS string showing only `name`, full-size.
275+
*/
276+
export function create_css_maximize_layout(
277+
name: string,
278+
physics: Physics = DEFAULT_PHYSICS,
279+
): string {
280+
return [
281+
host_template("100%", "100%"),
282+
child_template(physics, name, "1", "1"),
283+
].join("\n");
284+
}

src/model/overlay_controller.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ export class OverlayController {
5858

5959
const [col, row, box, style] = host.relativeCoordinates(event, true);
6060
let drop_target = calculate_intersection(col, row, panel);
61-
console.log(row, col, drop_target);
6261
if (drop_target) {
6362
drop_target = calculate_edge(
6463
col,

src/regular-layout-frame.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ const HTML_TEMPLATE = `
3131

3232
type DragState = { moved?: boolean; path: LayoutPath };
3333

34+
/**
35+
* Escapes a string for safe use as a CSS `<string>` token (the `content`
36+
* fallback), handling backslashes, double-quotes, and newlines.
37+
*/
38+
const css_string = (value: string): string =>
39+
`"${value.replace(/[\\"\n]/g, (c) => (c === "\n" ? "\\A " : `\\${c}`))}"`;
40+
41+
/**
42+
* The per-slot CSS custom property a consumer sets to override a tab's label,
43+
* e.g. `regular-layout { --regular-layout-my-panel--title: "My Panel"; }`.
44+
*/
45+
const title_variable = (slot: string): string =>
46+
`--regular-layout-${globalThis.CSS.escape(slot)}--title`;
47+
3448
/**
3549
* A custom element that represents a draggable panel within a
3650
* `<regular-layout>`.
@@ -47,8 +61,17 @@ type DragState = { moved?: boolean; path: LayoutPath };
4761
* [named `slot`s](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_templates_and_slots)
4862
* for wholesale replacement of the underlying Shadow DOM.
4963
*
64+
* The `name` attribute identifies the panel within the layout. The tab label
65+
* defaults to `name`, but can be overridden with pure CSS by setting the
66+
* `--regular-layout-<name>--title` custom property to a CSS string - the label
67+
* is rendered via the tab title's `::before` `content`, so the override
68+
* applies reactively with no JavaScript.
69+
*
5070
* @example
5171
* ```html
72+
* <style>
73+
* regular-layout { --regular-layout-panel-1--title: "Panel 1"; }
74+
* </style>
5275
* <regular-layout>
5376
* <regular-layout-frame name="panel-1">
5477
* <!-- Panel content here -->
@@ -59,6 +82,7 @@ type DragState = { moved?: boolean; path: LayoutPath };
5982
export class RegularLayoutFrame extends HTMLElement {
6083
private _shadowRoot!: ShadowRoot;
6184
private _container_sheet!: CSSStyleSheet;
85+
private _title_sheet!: CSSStyleSheet;
6286
private _layout!: RegularLayout;
6387
private _header!: HTMLElement;
6488
private _drag: DragState | null = null;
@@ -72,8 +96,12 @@ export class RegularLayoutFrame extends HTMLElement {
7296
connectedCallback() {
7397
this._container_sheet ??= new CSSStyleSheet();
7498
this._container_sheet.replaceSync(CSS);
99+
this._title_sheet ??= new CSSStyleSheet();
75100
this._shadowRoot ??= this.attachShadow({ mode: "open" });
76-
this._shadowRoot.adoptedStyleSheets = [this._container_sheet];
101+
this._shadowRoot.adoptedStyleSheets = [
102+
this._container_sheet,
103+
this._title_sheet,
104+
];
77105
this._shadowRoot.innerHTML = HTML_TEMPLATE;
78106
this._layout = this.parentElement as RegularLayout;
79107
this._header = this._shadowRoot.children[0] as HTMLElement;
@@ -106,6 +134,10 @@ export class RegularLayoutFrame extends HTMLElement {
106134
}
107135

108136
private onPointerDown = (event: PointerEvent): void => {
137+
if (event.button !== 0) {
138+
return;
139+
}
140+
109141
const elem = event.target as RegularLayoutTab;
110142
if (elem.part.contains("tab")) {
111143
const path = this._layout.calculateIntersect(event);
@@ -184,5 +216,28 @@ export class RegularLayoutFrame extends HTMLElement {
184216
for (let j = this._header.children.length - 1; j >= last_index; j--) {
185217
this._header.removeChild(this._header.children[j]);
186218
}
219+
220+
this.drawTitles(attr, new_tab_panel.tabs);
221+
};
222+
223+
/**
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.
229+
*
230+
* @param attr - The child name attribute (`CHILD_ATTRIBUTE_NAME`).
231+
* @param tabs - The slot names rendered in this frame's titlebar.
232+
*/
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);
187242
};
188243
}

src/regular-layout-tab.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export class RegularLayoutTab extends HTMLElement {
3434
* @param index - The index of this tab within the tab panel.
3535
*/
3636
populate = (layout: RegularLayout, tab_panel: TabLayout, index: number) => {
37+
// Stamp the slot onto the tab so its title can be styled by name (see
38+
// `RegularLayoutFrame#drawTitles`). Tag-qualified selectors keep this
39+
// distinct from the panel `name` on `<regular-layout-frame>`.
40+
this.setAttribute(
41+
layout.savePhysics().CHILD_ATTRIBUTE_NAME,
42+
tab_panel.tabs[index],
43+
);
44+
3745
if (this._tab_panel) {
3846
const tab_changed =
3947
(index === tab_panel.selected) !==
@@ -44,9 +52,6 @@ export class RegularLayoutTab extends HTMLElement {
4452

4553
if (index_changed) {
4654
const selected = tab_panel.selected === index;
47-
const slot = tab_panel.tabs[index];
48-
this.children[0].textContent = slot;
49-
5055
if (selected) {
5156
this.children[1].part.add("active-close");
5257
this.part.add("active-tab");
@@ -56,7 +61,6 @@ export class RegularLayoutTab extends HTMLElement {
5661
}
5762
}
5863
} else {
59-
const slot = tab_panel.tabs[index];
6064
const selected = tab_panel.selected === index;
6165
const parts = selected ? "active-close close" : "close";
6266
this.innerHTML = `<div part="title"></div><button part="${parts}"></button>`;
@@ -67,7 +71,6 @@ export class RegularLayoutTab extends HTMLElement {
6771
}
6872

6973
this.addEventListener("pointerdown", this.onTabClick);
70-
this.children[0].textContent = slot;
7174
this.children[1].addEventListener("pointerdown", this.onTabClose);
7275
}
7376

@@ -76,28 +79,23 @@ export class RegularLayoutTab extends HTMLElement {
7679
this._index = index;
7780
};
7881

79-
private onTabClose = (_: Event) => {
82+
private onTabClose = (event: Event) => {
83+
if (event instanceof PointerEvent && event?.button !== 0) {
84+
return;
85+
}
86+
8087
if (this._tab_panel !== undefined && this._index !== undefined) {
8188
this._layout?.removePanel(this._tab_panel.tabs[this._index]);
8289
}
8390
};
8491

85-
private onTabClick = (_: PointerEvent) => {
86-
if (
87-
this._tab_panel !== undefined &&
88-
this._index !== undefined &&
89-
this._index !== this._tab_panel.selected
90-
) {
91-
const new_layout = this._layout?.save();
92-
const new_tab_panel = this._layout?.getPanel(
93-
this._tab_panel.tabs[this._index],
94-
new_layout,
95-
);
92+
private onTabClick = (event: PointerEvent) => {
93+
if (event.button !== 0) {
94+
return;
95+
}
9696

97-
if (new_tab_panel && new_layout) {
98-
new_tab_panel.selected = this._index;
99-
this._layout?.restore(new_layout);
100-
}
97+
if (this._tab_panel !== undefined && this._index !== undefined) {
98+
this._layout?.select(this._tab_panel.tabs[this._index]);
10199
}
102100
};
103101
}

src/regular-layout.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
*/
1818

1919
import { EMPTY_PANEL } from "./core/types.ts";
20-
import { create_css_grid_layout } from "./layout/generate_grid.ts";
20+
import {
21+
create_css_grid_layout,
22+
create_css_maximize_layout,
23+
} from "./layout/generate_grid.ts";
2124
import type {
2225
LayoutPath,
2326
Layout,
@@ -106,6 +109,14 @@ export interface PointerEventCoordinates {
106109
* latter implementation would require synchronizing the light DOM
107110
* and shadow DOM slots/slotted children continuously.
108111
*
112+
* Note that `::slotted()` only matches *directly* slotted nodes - it
113+
* does not pierce a slotted `<slot>`. A consumer that wants to forward
114+
* its own light-DOM content through `<regular-layout>` therefore cannot
115+
* place a bare `<slot name="X">` as a layout child and expect it to be
116+
* grid-positioned; instead, wrap the forwarding slot in a concrete
117+
* `<regular-layout-frame name="X">` (which *is* directly slotted and so
118+
* matched by the generated `::slotted([name="X"])` rules).
119+
*
109120
*/
110121
export class RegularLayout extends HTMLElement {
111122
private _shadowRoot: ShadowRoot;
@@ -118,6 +129,7 @@ export class RegularLayout extends HTMLElement {
118129
private _physics: Physics;
119130
private _presizeQueue: PresizeQueue;
120131
private _overlayController: OverlayController;
132+
private _maximized?: string;
121133

122134
constructor() {
123135
super();
@@ -253,6 +265,36 @@ export class RegularLayout extends HTMLElement {
253265
await this.restore(remove_child(this._panel, name));
254266
};
255267

268+
/**
269+
* Selects a panel by `name`, making it the front-most tab within its
270+
* containing stack, then dispatches a `<prefix>-select` event with
271+
* `detail: { name }`.
272+
*
273+
* The event fires whenever a tab is selected - including re-selecting the
274+
* already-active tab, or selecting the sole tab of a single-element stack -
275+
* so consumers can always map a tab interaction to an "active panel". When
276+
* the selection actually changes, a `<prefix>-update` event is dispatched
277+
* first (via {@link restore}).
278+
*
279+
* @param name - The name of the panel to select.
280+
*/
281+
select = async (name: string) => {
282+
const layout = this.save();
283+
const tab_panel = this.getPanel(name, layout);
284+
if (!tab_panel) {
285+
return;
286+
}
287+
288+
const index = tab_panel.tabs.indexOf(name);
289+
if (index !== -1 && tab_panel.selected !== index) {
290+
tab_panel.selected = index;
291+
await this.restore(layout);
292+
}
293+
294+
const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-select`;
295+
this.dispatchEvent(new CustomEvent(event_name, { detail: { name } }));
296+
};
297+
256298
/**
257299
* Retrieves a panel by name from the layout tree.
258300
*
@@ -286,13 +328,50 @@ export class RegularLayout extends HTMLElement {
286328
await this.restore(EMPTY_PANEL);
287329
};
288330

331+
/**
332+
* Maximizes a panel by `name`, rendering it full-size and hiding every
333+
* other panel. This is transient display state - it is **not** persisted by
334+
* {@link save}, and any subsequent {@link restore} resets the layout to the
335+
* minimized (normal, multi-panel) view.
336+
*
337+
* Has no effect if `name` is not present in the current layout.
338+
*
339+
* @param name - The name of the panel to maximize.
340+
*/
341+
maximize = (name: string) => {
342+
if (!this.getPanel(name)) {
343+
return;
344+
}
345+
346+
this._maximized = name;
347+
this._stylesheet.replaceSync(
348+
create_css_maximize_layout(name, this._physics),
349+
);
350+
};
351+
352+
/**
353+
* Restores the normal multi-panel view after a {@link maximize}, without
354+
* altering the layout tree. No-op if no panel is currently maximized.
355+
*/
356+
minimize = () => {
357+
if (this._maximized === undefined) {
358+
return;
359+
}
360+
361+
this._maximized = undefined;
362+
this._stylesheet.replaceSync(
363+
create_css_grid_layout(this._panel, undefined, this._physics),
364+
);
365+
};
366+
289367
/**
290368
* Restores the layout from a saved state synchronously, without
291369
* dispatching the `regular-layout-before-resize` event.
292370
*
293371
* @param layout - The layout tree to restore
294372
*/
295373
restoreSync = (layout: Layout, _is_flattened: boolean = false) => {
374+
this._maximized = undefined;
296375
this._panel = !_is_flattened ? flatten(layout) : layout;
297376
const css = create_css_grid_layout(this._panel, undefined, this._physics);
298377
this._stylesheet.replaceSync(css);
@@ -320,13 +399,15 @@ export class RegularLayout extends HTMLElement {
320399
restore = async (layout: Layout, _is_flattened: boolean = false) => {
321400
const panel = !_is_flattened ? flatten(layout) : layout;
322401
await this._presizeQueue.run(panel, () => {
402+
this._maximized = undefined;
323403
this._panel = panel;
324404
const css = create_css_grid_layout(this._panel, undefined, this._physics);
325405
this._stylesheet.replaceSync(css);
326406
const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-update`;
327407
const event = new CustomEvent<Layout>(event_name, {
328408
detail: this._panel,
329409
});
410+
330411
this.dispatchEvent(event);
331412
});
332413
};
@@ -528,6 +609,10 @@ export class RegularLayout extends HTMLElement {
528609
};
529610

530611
private onPointerDown = (event: PointerEvent) => {
612+
if (event.button !== 0) {
613+
return;
614+
}
615+
531616
if (!this._physics.GRID_DIVIDER_CHECK_TARGET || event.target === this) {
532617
const [col, row, rect] = this.relativeCoordinates(event);
533618
const size = this._physics.GRID_DIVIDER_SIZE;

0 commit comments

Comments
 (0)