regular-layout 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +110 -22
  2. package/dist/{layout → core}/types.d.ts +6 -0
  3. package/dist/extensions.d.ts +13 -1
  4. package/dist/index.d.ts +2 -1
  5. package/dist/index.js +10 -7
  6. package/dist/index.js.map +4 -4
  7. package/dist/layout/calculate_edge.d.ts +2 -2
  8. package/dist/layout/calculate_intersect.d.ts +1 -1
  9. package/dist/layout/calculate_path.d.ts +1 -1
  10. package/dist/layout/calculate_presize_paths.d.ts +10 -0
  11. package/dist/layout/flatten.d.ts +1 -1
  12. package/dist/layout/generate_grid.d.ts +11 -2
  13. package/dist/layout/generate_overlay.d.ts +18 -2
  14. package/dist/layout/insert_child.d.ts +1 -1
  15. package/dist/layout/redistribute_panel_sizes.d.ts +2 -2
  16. package/dist/layout/remove_child.d.ts +1 -1
  17. package/dist/model/overlay_controller.d.ts +34 -0
  18. package/dist/model/presize_queue.d.ts +17 -0
  19. package/dist/regular-layout-frame.d.ts +21 -0
  20. package/dist/regular-layout-tab.d.ts +1 -1
  21. package/dist/regular-layout.d.ts +76 -9
  22. package/package.json +5 -4
  23. package/src/{layout → core}/constants.ts +2 -2
  24. package/src/{layout → core}/types.ts +7 -0
  25. package/src/extensions.ts +25 -1
  26. package/src/index.ts +3 -1
  27. package/src/layout/calculate_edge.ts +2 -2
  28. package/src/layout/calculate_intersect.ts +5 -2
  29. package/src/layout/calculate_path.ts +1 -1
  30. package/src/layout/calculate_presize_paths.ts +93 -0
  31. package/src/layout/flatten.ts +1 -1
  32. package/src/layout/generate_grid.ts +20 -2
  33. package/src/layout/generate_overlay.ts +48 -16
  34. package/src/layout/insert_child.ts +1 -1
  35. package/src/layout/redistribute_panel_sizes.ts +2 -2
  36. package/src/layout/remove_child.ts +2 -2
  37. package/src/model/overlay_controller.ts +161 -0
  38. package/src/model/presize_queue.ts +79 -0
  39. package/src/regular-layout-frame.ts +58 -3
  40. package/src/regular-layout-tab.ts +20 -22
  41. package/src/regular-layout.ts +252 -132
  42. package/themes/borland.css +103 -0
  43. package/themes/chicago.css +55 -49
  44. package/themes/fluxbox.css +64 -60
  45. package/themes/gibson.css +174 -164
  46. package/themes/hotdog.css +53 -47
  47. package/themes/lorax.css +82 -75
  48. /package/dist/{layout → core}/constants.d.ts +0 -0
@@ -9,8 +9,8 @@
9
9
  // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
10
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
11
 
12
- import { DEFAULT_PHYSICS, type Physics } from "./constants.ts";
13
- import type { Layout } from "./types.ts";
12
+ import { DEFAULT_PHYSICS, type Physics } from "../core/constants.ts";
13
+ import type { Layout } from "../core/types.ts";
14
14
 
15
15
  interface GridCell {
16
16
  child: string;
@@ -264,3 +264,21 @@ export function create_css_grid_layout(
264
264
 
265
265
  return css.join("\n");
266
266
  }
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
+ }
@@ -9,8 +9,46 @@
9
9
  // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
10
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
11
 
12
- import { DEFAULT_PHYSICS } from "./constants";
13
- import type { LayoutPath } from "./types";
12
+ import { DEFAULT_PHYSICS } from "../core/constants";
13
+ import type { LayoutPath, ViewWindow } from "../core/types";
14
+
15
+ /**
16
+ * Converts a {@link ViewWindow} to element-relative pixel coordinates,
17
+ * accounting for padding and optionally CSS `gap` and child `margin`.
18
+ *
19
+ * @param window - The view window in normalized 0–1 coordinates.
20
+ * @param box - The element's bounding client rect.
21
+ * @param style - The element's computed style (for padding).
22
+ * @param margin - Optional child element's computed style (for margin inset).
23
+ * @returns Pixel coordinates relative to the element's border-box origin.
24
+ */
25
+ export function viewWindowToLocalRect(
26
+ window: ViewWindow,
27
+ box: DOMRect,
28
+ style: CSSStyleDeclaration,
29
+ margin?: CSSStyleDeclaration,
30
+ ): { x: number; y: number; width: number; height: number } {
31
+ const paddingLeft = parseFloat(style.paddingLeft);
32
+ const paddingTop = parseFloat(style.paddingTop);
33
+ const contentWidth = box.width - paddingLeft - parseFloat(style.paddingRight);
34
+ const contentHeight =
35
+ box.height - paddingTop - parseFloat(style.paddingBottom);
36
+
37
+ const x = paddingLeft + window.col_start * contentWidth;
38
+ const y = paddingTop + window.row_start * contentHeight;
39
+ let width = (window.col_end - window.col_start) * contentWidth;
40
+ let height = (window.row_end - window.row_start) * contentHeight;
41
+ if (margin) {
42
+ const marginTop = parseFloat(margin.marginTop);
43
+ const marginRight = parseFloat(margin.marginRight);
44
+ const marginBottom = parseFloat(margin.marginBottom);
45
+ const marginLeft = parseFloat(margin.marginLeft);
46
+ width -= marginLeft + marginRight;
47
+ height -= marginTop + marginBottom;
48
+ }
49
+
50
+ return { x, y, width, height };
51
+ }
14
52
 
15
53
  export function updateOverlaySheet(
16
54
  slot: string,
@@ -18,25 +56,19 @@ export function updateOverlaySheet(
18
56
  style: CSSStyleDeclaration,
19
57
  drag_target: LayoutPath | null,
20
58
  physics = DEFAULT_PHYSICS,
59
+ margin?: CSSStyleDeclaration,
21
60
  ) {
22
61
  if (!drag_target) {
23
62
  return `:host ::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){display:none;}`;
24
63
  }
25
64
 
26
- const {
27
- view_window: { row_start, row_end, col_start, col_end },
28
- } = drag_target;
29
-
30
- const box_height =
31
- box.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
32
-
33
- const box_width =
34
- box.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
65
+ const local = viewWindowToLocalRect(
66
+ drag_target.view_window,
67
+ box,
68
+ style,
69
+ margin,
70
+ );
35
71
 
36
- const top = row_start * box_height + parseFloat(style.paddingTop);
37
- const left = col_start * box_width + parseFloat(style.paddingLeft);
38
- const height = (row_end - row_start) * box_height;
39
- const width = (col_end - col_start) * box_width;
40
- const css = `display:flex;position:absolute!important;z-index:1;top:${top}px;left:${left}px;height:${height}px;width:${width}px;`;
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;`;
41
73
  return `::slotted([${physics.CHILD_ATTRIBUTE_NAME}="${slot}"]){${css}}`;
42
74
  }
@@ -9,7 +9,7 @@
9
9
  // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
10
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
11
 
12
- import type { Layout, LayoutPathTraversal } from "./types.ts";
12
+ import type { Layout, LayoutPathTraversal } from "../core/types.ts";
13
13
 
14
14
  /**
15
15
  * Inserts a new child panel into the layout tree at a specified location.
@@ -9,8 +9,8 @@
9
9
  // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
10
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
11
 
12
- import { DEFAULT_PHYSICS } from "./constants.ts";
13
- import type { Layout, LayoutPathTraversal } from "./types.ts";
12
+ import { DEFAULT_PHYSICS } from "../core/constants.ts";
13
+ import type { Layout, LayoutPathTraversal } from "../core/types.ts";
14
14
 
15
15
  /**
16
16
  * Adjusts panel sizes during a drag operation on a divider.
@@ -9,8 +9,8 @@
9
9
  // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
10
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
11
 
12
- import type { Layout, TabLayout } from "./types.ts";
13
- import { EMPTY_PANEL } from "./types.ts";
12
+ import type { Layout, TabLayout } from "../core/types.ts";
13
+ import { EMPTY_PANEL } from "../core/types.ts";
14
14
 
15
15
  /**
16
16
  * Removes a child panel from the layout tree by its name.
@@ -0,0 +1,161 @@
1
+ // ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
2
+ // ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░
3
+ // ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░
4
+ // ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░
5
+ // ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
6
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
7
+ // ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃
8
+ // ┃ * of the Regular Layout library, distributed under the terms of the * ┃
9
+ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
+
12
+ import type { Layout, LayoutPath, OverlayMode } from "../core/types.ts";
13
+ import type { Physics } from "../core/constants.ts";
14
+ import type { PresizeQueue } from "./presize_queue.ts";
15
+ import { calculate_intersection } from "../layout/calculate_intersect.ts";
16
+ import { calculate_edge } from "../layout/calculate_edge.ts";
17
+ import { create_css_grid_layout } from "../layout/generate_grid.ts";
18
+ import { updateOverlaySheet } from "../layout/generate_overlay.ts";
19
+ import { remove_child } from "../layout/remove_child.ts";
20
+ import { insert_child } from "../layout/insert_child.ts";
21
+
22
+ export interface OverlayHost {
23
+ readonly panel: Layout;
24
+ readonly physics: Physics;
25
+ readonly stylesheet: CSSStyleSheet;
26
+ readonly presizeQueue: PresizeQueue;
27
+ relativeCoordinates(
28
+ event: { clientX: number; clientY: number },
29
+ recalculate: boolean,
30
+ ): [number, number, DOMRect, CSSStyleDeclaration];
31
+ restore(layout: Layout, isFlattened?: boolean): Promise<void>;
32
+ querySelector(selectors: string): Element | null;
33
+ dispatchEvent(event: Event): boolean;
34
+ }
35
+
36
+ /**
37
+ * Manages overlay state during drag-and-drop panel rearrangement.
38
+ *
39
+ * Handles rendering a preview of where a dragged panel would land,
40
+ * and committing or cancelling the placement when the drag ends.
41
+ */
42
+ export class OverlayController {
43
+ constructor(private _host: OverlayHost) {}
44
+
45
+ async set(
46
+ event: { clientX: number; clientY: number },
47
+ { slot }: LayoutPath,
48
+ className: string = this._host.physics.OVERLAY_CLASSNAME,
49
+ mode: OverlayMode = this._host.physics.OVERLAY_DEFAULT,
50
+ ): Promise<void> {
51
+ const host = this._host;
52
+ const panel = remove_child(host.panel, slot);
53
+ const query = `:scope > [${host.physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`;
54
+ let drag_element = host.querySelector(query);
55
+ if (drag_element) {
56
+ drag_element.classList.add(className);
57
+ }
58
+
59
+ const [col, row, box, style] = host.relativeCoordinates(event, true);
60
+ let drop_target = calculate_intersection(col, row, panel);
61
+ if (drop_target) {
62
+ drop_target = calculate_edge(
63
+ col,
64
+ row,
65
+ panel,
66
+ slot,
67
+ drop_target,
68
+ box,
69
+ host.physics,
70
+ );
71
+ }
72
+
73
+ await host.presizeQueue.run(panel, () => {
74
+ if (mode === "grid" && drop_target) {
75
+ const path: [string, string] = [slot, drop_target?.slot];
76
+ const css = create_css_grid_layout(panel, path, host.physics);
77
+ host.stylesheet.replaceSync(css);
78
+ } else if (mode === "absolute") {
79
+ const grid_css = create_css_grid_layout(panel, undefined, host.physics);
80
+
81
+ while (drag_element?.tagName === "SLOT" && drag_element) {
82
+ drag_element = (
83
+ drag_element as HTMLSlotElement
84
+ ).assignedElements()[0];
85
+ }
86
+
87
+ const margin = drag_element
88
+ ? getComputedStyle(drag_element)
89
+ : undefined;
90
+
91
+ const overlay_css = updateOverlaySheet(
92
+ slot,
93
+ box,
94
+ style,
95
+ drop_target,
96
+ host.physics,
97
+ margin,
98
+ );
99
+
100
+ host.stylesheet.replaceSync([grid_css, overlay_css].join("\n"));
101
+ }
102
+ });
103
+
104
+ const event_name = `${host.physics.CUSTOM_EVENT_NAME_PREFIX}-before-update`;
105
+ const custom_event = new CustomEvent<Layout>(event_name, {
106
+ detail: panel,
107
+ });
108
+ host.dispatchEvent(custom_event);
109
+ }
110
+
111
+ async clear(
112
+ event: { clientX: number; clientY: number } | null,
113
+ { slot, layout }: LayoutPath,
114
+ className: string = this._host.physics.OVERLAY_CLASSNAME,
115
+ ): Promise<void> {
116
+ const host = this._host;
117
+ const panel = remove_child(host.panel, slot);
118
+ const query = `:scope > [${host.physics.CHILD_ATTRIBUTE_NAME}="${slot}"]`;
119
+ const drag_element = host.querySelector(query);
120
+
121
+ if (event === null) {
122
+ await host.restore(layout);
123
+ return;
124
+ }
125
+
126
+ const [col, row, box] = host.relativeCoordinates(event, false);
127
+ let drop_target = calculate_intersection(col, row, panel);
128
+ if (drop_target) {
129
+ drop_target = calculate_edge(
130
+ col,
131
+ row,
132
+ panel,
133
+ slot,
134
+ drop_target,
135
+ box,
136
+ host.physics,
137
+ );
138
+ }
139
+
140
+ if (drop_target) {
141
+ const orientation = drop_target?.is_edge
142
+ ? drop_target.orientation
143
+ : undefined;
144
+
145
+ const new_layout = insert_child(
146
+ panel,
147
+ slot,
148
+ drop_target.path,
149
+ orientation,
150
+ );
151
+
152
+ await host.restore(new_layout);
153
+ } else {
154
+ await host.restore(layout);
155
+ }
156
+
157
+ if (drag_element) {
158
+ drag_element.classList.remove(className);
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,79 @@
1
+ // ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
2
+ // ░░░░░░░░▄▀░█▀▄░█▀▀░█▀▀░█░█░█░░░█▀█░█▀▄░░░░░█░░░█▀█░█░█░█▀█░█░█░▀█▀░▀▄░░░░░░░░
3
+ // ░░░░░░░▀▄░░█▀▄░█▀▀░█░█░█░█░█░░░█▀█░█▀▄░▀▀▀░█░░░█▀█░░█░░█░█░█░█░░█░░░▄▀░░░░░░░
4
+ // ░░░░░░░░░▀░▀░▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀░▀░▀░▀░░░░░▀▀▀░▀░▀░░▀░░▀▀▀░▀▀▀░░▀░░▀░░░░░░░░░
5
+ // ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
6
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
7
+ // ┃ * Copyright (c) 2026, the Regular Layout Authors. This file is part * ┃
8
+ // ┃ * of the Regular Layout library, distributed under the terms of the * ┃
9
+ // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
+
12
+ import type { Layout, PresizeDetail } from "../core/types.ts";
13
+ import { calculate_presize_paths } from "../layout/calculate_presize_paths.ts";
14
+
15
+ /**
16
+ * Manages cancelable pre-resize gating for layout updates.
17
+ *
18
+ * Before each layout change, a cancelable `resize-before` event is dispatched
19
+ * on the target. If the event is cancelled via `preventDefault()`, the update
20
+ * is suspended until {@link resume} is called. Concurrent updates are queued
21
+ * and processed sequentially.
22
+ */
23
+ export class PresizeQueue {
24
+ #resizing = false;
25
+ #queued: { layout: Layout; fn: () => void } | null = null;
26
+ #pending: (() => void) | null = null;
27
+
28
+ constructor(
29
+ private _target: EventTarget,
30
+ private _eventName: string,
31
+ ) {}
32
+
33
+ async run(layout: Layout, fn: () => void): Promise<void> {
34
+ if (this.#resizing) {
35
+ this.#queued = { layout, fn };
36
+ return;
37
+ }
38
+
39
+ this.#resizing = true;
40
+ try {
41
+ await this.#dispatchAndMaybeWait(layout, fn);
42
+ while (this.#queued) {
43
+ const { layout: nextLayout, fn: nextFn } = this.#queued;
44
+ this.#queued = null;
45
+ await this.#dispatchAndMaybeWait(nextLayout, nextFn);
46
+ }
47
+ } finally {
48
+ this.#resizing = false;
49
+ }
50
+ }
51
+
52
+ resume(): void {
53
+ if (this.#pending) {
54
+ const resolve = this.#pending;
55
+ this.#pending = null;
56
+ resolve();
57
+ }
58
+ }
59
+
60
+ async #dispatchAndMaybeWait(layout: Layout, fn: () => void): Promise<void> {
61
+ const detail: PresizeDetail = {
62
+ calculatePresizePaths: () => calculate_presize_paths(layout),
63
+ };
64
+
65
+ const event = new CustomEvent(this._eventName, {
66
+ cancelable: true,
67
+ detail,
68
+ });
69
+
70
+ const proceed = this._target.dispatchEvent(event);
71
+ if (!proceed) {
72
+ await new Promise<void>((resolve) => {
73
+ this.#pending = resolve;
74
+ });
75
+ }
76
+
77
+ fn();
78
+ }
79
+ }
@@ -9,7 +9,7 @@
9
9
  // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
10
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
11
 
12
- import type { LayoutPath } from "./layout/types.ts";
12
+ import type { LayoutPath } from "./core/types.ts";
13
13
  import type { RegularLayoutEvent } from "./extensions.ts";
14
14
  import type { RegularLayout } from "./regular-layout.ts";
15
15
  import type { RegularLayoutTab } from "./regular-layout-tab.ts";
@@ -26,11 +26,25 @@ const CSS = `
26
26
 
27
27
  const HTML_TEMPLATE = `
28
28
  <div part="titlebar"></div>
29
- <slot part="container"></slot>
29
+ <div part="container"><slot></slot></div>
30
30
  `;
31
31
 
32
32
  type DragState = { moved?: boolean; path: LayoutPath };
33
33
 
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
+
34
48
  /**
35
49
  * A custom element that represents a draggable panel within a
36
50
  * `<regular-layout>`.
@@ -47,8 +61,17 @@ type DragState = { moved?: boolean; path: LayoutPath };
47
61
  * [named `slot`s](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_templates_and_slots)
48
62
  * for wholesale replacement of the underlying Shadow DOM.
49
63
  *
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
+ *
50
70
  * @example
51
71
  * ```html
72
+ * <style>
73
+ * regular-layout { --regular-layout-panel-1--title: "Panel 1"; }
74
+ * </style>
52
75
  * <regular-layout>
53
76
  * <regular-layout-frame name="panel-1">
54
77
  * <!-- Panel content here -->
@@ -59,6 +82,7 @@ type DragState = { moved?: boolean; path: LayoutPath };
59
82
  export class RegularLayoutFrame extends HTMLElement {
60
83
  private _shadowRoot!: ShadowRoot;
61
84
  private _container_sheet!: CSSStyleSheet;
85
+ private _title_sheet!: CSSStyleSheet;
62
86
  private _layout!: RegularLayout;
63
87
  private _header!: HTMLElement;
64
88
  private _drag: DragState | null = null;
@@ -72,8 +96,12 @@ export class RegularLayoutFrame extends HTMLElement {
72
96
  connectedCallback() {
73
97
  this._container_sheet ??= new CSSStyleSheet();
74
98
  this._container_sheet.replaceSync(CSS);
99
+ this._title_sheet ??= new CSSStyleSheet();
75
100
  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
+ ];
77
105
  this._shadowRoot.innerHTML = HTML_TEMPLATE;
78
106
  this._layout = this.parentElement as RegularLayout;
79
107
  this._header = this._shadowRoot.children[0] as HTMLElement;
@@ -106,6 +134,10 @@ export class RegularLayoutFrame extends HTMLElement {
106
134
  }
107
135
 
108
136
  private onPointerDown = (event: PointerEvent): void => {
137
+ if (event.button !== 0) {
138
+ return;
139
+ }
140
+
109
141
  const elem = event.target as RegularLayoutTab;
110
142
  if (elem.part.contains("tab")) {
111
143
  const path = this._layout.calculateIntersect(event);
@@ -184,5 +216,28 @@ export class RegularLayoutFrame extends HTMLElement {
184
216
  for (let j = this._header.children.length - 1; j >= last_index; j--) {
185
217
  this._header.removeChild(this._header.children[j]);
186
218
  }
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);
187
242
  };
188
243
  }
@@ -9,7 +9,7 @@
9
9
  // ┃ * [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). * ┃
10
10
  // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
11
11
 
12
- import type { TabLayout } from "./layout/types.ts";
12
+ import type { TabLayout } from "./core/types.ts";
13
13
  import type { RegularLayout } from "./regular-layout.ts";
14
14
 
15
15
  /**
@@ -34,6 +34,14 @@ export class RegularLayoutTab extends HTMLElement {
34
34
  * @param index - The index of this tab within the tab panel.
35
35
  */
36
36
  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
+
37
45
  if (this._tab_panel) {
38
46
  const tab_changed =
39
47
  (index === tab_panel.selected) !==
@@ -44,9 +52,6 @@ export class RegularLayoutTab extends HTMLElement {
44
52
 
45
53
  if (index_changed) {
46
54
  const selected = tab_panel.selected === index;
47
- const slot = tab_panel.tabs[index];
48
- this.children[0].textContent = slot;
49
-
50
55
  if (selected) {
51
56
  this.children[1].part.add("active-close");
52
57
  this.part.add("active-tab");
@@ -56,7 +61,6 @@ export class RegularLayoutTab extends HTMLElement {
56
61
  }
57
62
  }
58
63
  } else {
59
- const slot = tab_panel.tabs[index];
60
64
  const selected = tab_panel.selected === index;
61
65
  const parts = selected ? "active-close close" : "close";
62
66
  this.innerHTML = `<div part="title"></div><button part="${parts}"></button>`;
@@ -67,7 +71,6 @@ export class RegularLayoutTab extends HTMLElement {
67
71
  }
68
72
 
69
73
  this.addEventListener("pointerdown", this.onTabClick);
70
- this.children[0].textContent = slot;
71
74
  this.children[1].addEventListener("pointerdown", this.onTabClose);
72
75
  }
73
76
 
@@ -76,28 +79,23 @@ export class RegularLayoutTab extends HTMLElement {
76
79
  this._index = index;
77
80
  };
78
81
 
79
- private onTabClose = (_: Event) => {
82
+ private onTabClose = (event: Event) => {
83
+ if (event instanceof PointerEvent && event?.button !== 0) {
84
+ return;
85
+ }
86
+
80
87
  if (this._tab_panel !== undefined && this._index !== undefined) {
81
88
  this._layout?.removePanel(this._tab_panel.tabs[this._index]);
82
89
  }
83
90
  };
84
91
 
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
+ }
96
96
 
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]);
101
99
  }
102
100
  };
103
101
  }