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
12 changes: 12 additions & 0 deletions src/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<Layout>;
export type RegularLayoutPresizeEvent = CustomEvent<PresizeDetail>;
export type RegularLayoutSelectEvent = CustomEvent<{ name: string }>;
18 changes: 18 additions & 0 deletions src/layout/generate_grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
1 change: 0 additions & 1 deletion src/model/overlay_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
57 changes: 56 additions & 1 deletion src/regular-layout-frame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,20 @@ const HTML_TEMPLATE = `

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

/**
* Escapes a string for safe use as a CSS `<string>` 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
* `<regular-layout>`.
Expand All @@ -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-<name>--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
* <style>
* regular-layout { --regular-layout-panel-1--title: "Panel 1"; }
* </style>
* <regular-layout>
* <regular-layout-frame name="panel-1">
* <!-- Panel content here -->
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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-<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.
*/
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);
};
}
40 changes: 19 additions & 21 deletions src/regular-layout-tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<regular-layout-frame>`.
this.setAttribute(
layout.savePhysics().CHILD_ATTRIBUTE_NAME,
tab_panel.tabs[index],
);

if (this._tab_panel) {
const tab_changed =
(index === tab_panel.selected) !==
Expand All @@ -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");
Expand All @@ -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 = `<div part="title"></div><button part="${parts}"></button>`;
Expand All @@ -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);
}

Expand All @@ -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]);
}
};
}
87 changes: 86 additions & 1 deletion src/regular-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<slot>`. A consumer that wants to forward
* its own light-DOM content through `<regular-layout>` therefore cannot
* place a bare `<slot name="X">` as a layout child and expect it to be
* grid-positioned; instead, wrap the forwarding slot in a concrete
* `<regular-layout-frame name="X">` (which *is* directly slotted and so
* matched by the generated `::slotted([name="X"])` rules).
*
*/
export class RegularLayout extends HTMLElement {
private _shadowRoot: ShadowRoot;
Expand All @@ -118,6 +129,7 @@ export class RegularLayout extends HTMLElement {
private _physics: Physics;
private _presizeQueue: PresizeQueue;
private _overlayController: OverlayController;
private _maximized?: string;

constructor() {
super();
Expand Down Expand Up @@ -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 `<prefix>-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 `<prefix>-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.
*
Expand Down Expand Up @@ -286,13 +328,50 @@ 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.
*
* @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);
Expand Down Expand Up @@ -320,13 +399,15 @@ 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);
const event_name = `${this._physics.CUSTOM_EVENT_NAME_PREFIX}-update`;
const event = new CustomEvent<Layout>(event_name, {
detail: this._panel,
});

this.dispatchEvent(event);
});
};
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading