@adia-ai/web-modules 0.3.5 → 0.4.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.
- package/CHANGELOG.md +55 -0
- package/chat/chat-shell/chat-shell.js +28 -40
- package/chat/chat-shell/css/chat-shell.empty.css +3 -3
- package/chat/chat-shell/css/chat-shell.layout.css +2 -2
- package/editor/editor-canvas/editor-canvas.a2ui.json +87 -0
- package/editor/editor-canvas/editor-canvas.examples.html +65 -0
- package/editor/editor-canvas/editor-canvas.html +43 -0
- package/editor/editor-canvas/editor-canvas.js +103 -0
- package/editor/editor-canvas/editor-canvas.test.js +100 -0
- package/editor/editor-canvas/editor-canvas.yaml +88 -0
- package/editor/editor-canvas-empty/editor-canvas-empty.a2ui.json +69 -0
- package/editor/editor-canvas-empty/editor-canvas-empty.examples.html +65 -0
- package/editor/editor-canvas-empty/editor-canvas-empty.html +42 -0
- package/editor/editor-canvas-empty/editor-canvas-empty.yaml +56 -0
- package/editor/editor-shell/css/editor-shell.bespoke.css +237 -0
- package/editor/editor-shell/css/editor-shell.layout.css +6 -6
- package/editor/editor-shell/editor-shell.css +1 -0
- package/editor/editor-shell/editor-shell.js +87 -30
- package/editor/editor-sidebar/editor-sidebar.a2ui.json +93 -0
- package/editor/editor-sidebar/editor-sidebar.examples.html +65 -0
- package/editor/editor-sidebar/editor-sidebar.html +43 -0
- package/editor/editor-sidebar/editor-sidebar.js +197 -0
- package/editor/editor-sidebar/editor-sidebar.test.js +145 -0
- package/editor/editor-sidebar/editor-sidebar.yaml +91 -0
- package/editor/editor-statusbar/editor-statusbar.a2ui.json +76 -0
- package/editor/editor-statusbar/editor-statusbar.examples.html +65 -0
- package/editor/editor-statusbar/editor-statusbar.html +42 -0
- package/editor/editor-statusbar/editor-statusbar.yaml +57 -0
- package/editor/editor-toolbar/editor-toolbar.a2ui.json +96 -0
- package/editor/editor-toolbar/editor-toolbar.examples.html +65 -0
- package/editor/editor-toolbar/editor-toolbar.html +43 -0
- package/editor/editor-toolbar/editor-toolbar.js +58 -0
- package/editor/editor-toolbar/editor-toolbar.test.js +99 -0
- package/editor/editor-toolbar/editor-toolbar.yaml +81 -0
- package/editor/index.js +3 -0
- package/package.json +4 -4
- package/shell/admin-shell/admin-shell.js +27 -243
- package/shell/admin-shell/css/admin-shell.bespoke.css +22 -26
- package/shell/admin-shell/css/admin-shell.main.css +2 -2
- package/shell/admin-shell/css/admin-shell.shell.css +2 -2
- package/shell/admin-shell/css/admin-shell.sidebar.css +35 -33
|
@@ -1,47 +1,104 @@
|
|
|
1
1
|
import { UIElement } from '../../../web-components/core/element.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* <editor-shell
|
|
4
|
+
* <editor-shell focus-mode?>
|
|
5
|
+
* <editor-toolbar>...</editor-toolbar>
|
|
6
|
+
* <editor-sidebar slot="leading">
|
|
7
|
+
* <pane-ui resizable>...</pane-ui>
|
|
8
|
+
* </editor-sidebar>
|
|
9
|
+
* <editor-canvas>...</editor-canvas>
|
|
10
|
+
* <editor-sidebar slot="trailing">
|
|
11
|
+
* <pane-ui resizable>...</pane-ui>
|
|
12
|
+
* </editor-sidebar>
|
|
13
|
+
* <editor-statusbar>...</editor-statusbar>
|
|
14
|
+
* </editor-shell>
|
|
5
15
|
*
|
|
6
|
-
* Behavior-only orchestrator for design
|
|
16
|
+
* Behavior-only orchestrator for design-tool UIs:
|
|
7
17
|
* topbar, navigator pane, center canvas, inspector pane, bottombar.
|
|
8
18
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* </div>
|
|
30
|
-
* <footer>
|
|
31
|
-
* <span>Ready</span>
|
|
32
|
-
* <span data-spacer></span>
|
|
33
|
-
* <span>100%</span>
|
|
34
|
-
* </footer>
|
|
35
|
-
* </editor-shell>
|
|
19
|
+
* **Bespoke-only since v0.4.0** (ADR-0023 Phase 3). The legacy shapes
|
|
20
|
+
* (<header>, <div data-editor-body>, <pane-ui data-left|data-right>,
|
|
21
|
+
* <div data-canvas>, <footer>) are no longer recognized. The editor
|
|
22
|
+
* cluster has the smallest bespoke family of the three (admin / chat /
|
|
23
|
+
* editor) — only 3 JS-bearing children + 2 CSS-only structural stubs —
|
|
24
|
+
* because <pane-ui> already owns resize and the editor doesn't need
|
|
25
|
+
* a command palette.
|
|
26
|
+
*
|
|
27
|
+
* Reflected attributes:
|
|
28
|
+
* [focus-mode] — distraction-free / canvas-focus mode; consumers
|
|
29
|
+
* can hide toolbar/sidebars/statusbar via CSS:
|
|
30
|
+
* editor-shell[focus-mode] editor-toolbar { display: none; }
|
|
31
|
+
*
|
|
32
|
+
* Events:
|
|
33
|
+
* editor-mode-change — fires when [focus-mode] is toggled
|
|
34
|
+
*
|
|
35
|
+
* Public methods:
|
|
36
|
+
* .toggleFocusMode() — flip [focus-mode]; also propagates to
|
|
37
|
+
* child <editor-toolbar>'s [full-screen]
|
|
38
|
+
* and <editor-canvas>'s [focused] attributes
|
|
36
39
|
*/
|
|
37
40
|
class EditorShell extends UIElement {
|
|
41
|
+
static properties = {
|
|
42
|
+
focusMode: { type: Boolean, default: false, reflect: true, attribute: 'focus-mode' },
|
|
43
|
+
};
|
|
44
|
+
|
|
38
45
|
static template = () => null;
|
|
39
46
|
|
|
47
|
+
#toolbarEl = null;
|
|
48
|
+
#canvasEl = null;
|
|
49
|
+
#onToolbarAction = null;
|
|
50
|
+
|
|
40
51
|
connected() {
|
|
41
|
-
//
|
|
52
|
+
// **Bespoke-only since v0.4.0** (ADR-0023 Phase 3). The legacy
|
|
53
|
+
// shapes (<header>, <div data-canvas>, <div data-editor-body>,
|
|
54
|
+
// <pane-ui data-left|data-right>, <footer>) are no longer
|
|
55
|
+
// recognized. Consumers MUST use the bespoke vocabulary.
|
|
56
|
+
this.#toolbarEl = this.querySelector('editor-toolbar');
|
|
57
|
+
this.#canvasEl = this.querySelector('editor-canvas');
|
|
58
|
+
|
|
59
|
+
// Wire select options inside the toolbar / sidebars
|
|
42
60
|
this.#wireSelects();
|
|
61
|
+
|
|
62
|
+
// Listen for toolbar-action bubbling from <editor-toolbar>
|
|
63
|
+
this.#onToolbarAction = (e) => {
|
|
64
|
+
const name = e?.detail?.name;
|
|
65
|
+
if (!name) return;
|
|
66
|
+
if (name === 'toggle-focus' || name === 'full-screen') {
|
|
67
|
+
this.toggleFocusMode();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
this.addEventListener('toolbar-action', this.#onToolbarAction);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
disconnected() {
|
|
74
|
+
if (this.#onToolbarAction) {
|
|
75
|
+
this.removeEventListener('toolbar-action', this.#onToolbarAction);
|
|
76
|
+
}
|
|
77
|
+
this.#onToolbarAction = null;
|
|
43
78
|
}
|
|
44
79
|
|
|
80
|
+
// ── Public API ──
|
|
81
|
+
|
|
82
|
+
toggleFocusMode() {
|
|
83
|
+
this.focusMode = !this.focusMode;
|
|
84
|
+
|
|
85
|
+
// Propagate to bespoke children if present
|
|
86
|
+
if (this.#toolbarEl?.tagName?.toLowerCase() === 'editor-toolbar') {
|
|
87
|
+
this.#toolbarEl.fullScreen = this.focusMode;
|
|
88
|
+
}
|
|
89
|
+
if (this.#canvasEl?.tagName?.toLowerCase() === 'editor-canvas') {
|
|
90
|
+
if (this.focusMode) this.#canvasEl.focus?.();
|
|
91
|
+
else this.#canvasEl.blur?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
this.dispatchEvent(new CustomEvent('editor-mode-change', {
|
|
95
|
+
bubbles: true,
|
|
96
|
+
detail: { focusMode: this.focusMode },
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Internal ──
|
|
101
|
+
|
|
45
102
|
#wireSelects() {
|
|
46
103
|
for (const sel of this.querySelectorAll('select-ui[data-options]')) {
|
|
47
104
|
try {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://adiaui.dev/a2ui/v0_9/components/EditorSidebar.json",
|
|
4
|
+
"title": "EditorSidebar",
|
|
5
|
+
"description": "Module-tier editor-cluster sidebar — wraps <pane-ui resizable>\n(the primitive that owns drag) and adds [collapsed] reflected\nstate, localStorage persistence (adia-editor-sidebar-{name}),\nand .toggle() / .collapse() / .expand() public API.\n\nSits inside <editor-shell> as slot=\"leading\" (navigator pane) or\nslot=\"trailing\" (inspector pane). Mirrors the cluster-namespace\nshape from <admin-sidebar> and <chat-sidebar>, but DELEGATES to\n<pane-ui> for resize rather than reimplementing it (per\nbespoke-shell-children skill §When NOT to promote — pane primitive\nalready owns this concern).\n\nThis is the FIRST bespoke shell child that delegates rather than\nduplicates a primitive's behavior. Pattern is: cluster-namespace +\nstate-as-attribute + persistence at the bespoke tier; physical\ndrag at the primitive tier.\n",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"allOf": [
|
|
8
|
+
{
|
|
9
|
+
"$ref": "common_types.json#/$defs/ComponentCommon"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"$ref": "common_types.json#/$defs/CatalogComponentCommon"
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"properties": {
|
|
16
|
+
"collapsed": {
|
|
17
|
+
"description": "Reflected — set when inner <pane-ui> width is at or below 96px snap threshold.",
|
|
18
|
+
"type": "boolean",
|
|
19
|
+
"default": false
|
|
20
|
+
},
|
|
21
|
+
"component": {
|
|
22
|
+
"const": "EditorSidebar"
|
|
23
|
+
},
|
|
24
|
+
"resizing": {
|
|
25
|
+
"description": "Reflected — set during an active pointer-drag of the pane's\nresize handle. Used to disable transitions and visual treatments\nthat would feel laggy during drag.\n",
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"default": false
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"required": [
|
|
31
|
+
"component"
|
|
32
|
+
],
|
|
33
|
+
"unevaluatedProperties": false,
|
|
34
|
+
"x-adiaui": {
|
|
35
|
+
"anti_patterns": [],
|
|
36
|
+
"category": "layout",
|
|
37
|
+
"events": {
|
|
38
|
+
"sidebar-toggle": {
|
|
39
|
+
"description": "Bubbles when .toggle() / .collapse() / .expand() is called.",
|
|
40
|
+
"detail": {
|
|
41
|
+
"expanded": "boolean",
|
|
42
|
+
"name": "string"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"examples": [],
|
|
47
|
+
"keywords": [
|
|
48
|
+
"editor-sidebar",
|
|
49
|
+
"navigator-pane",
|
|
50
|
+
"inspector-pane",
|
|
51
|
+
"editor-rail",
|
|
52
|
+
"sidebar",
|
|
53
|
+
"leading",
|
|
54
|
+
"trailing"
|
|
55
|
+
],
|
|
56
|
+
"name": "EditorSidebar",
|
|
57
|
+
"related": [
|
|
58
|
+
"EditorShell",
|
|
59
|
+
"EditorCanvas",
|
|
60
|
+
"Pane",
|
|
61
|
+
"AdminSidebar",
|
|
62
|
+
"ChatSidebar"
|
|
63
|
+
],
|
|
64
|
+
"slots": {
|
|
65
|
+
"default": {
|
|
66
|
+
"description": "Default — the inner <pane-ui resizable> wrapper. Authors compose header / section / footer inside the pane as usual."
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"states": [
|
|
70
|
+
{
|
|
71
|
+
"description": "Default expanded state.",
|
|
72
|
+
"name": "idle"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"description": "Inner pane is at or below the snap threshold.",
|
|
76
|
+
"attribute": "collapsed",
|
|
77
|
+
"name": "collapsed"
|
|
78
|
+
}
|
|
79
|
+
],
|
|
80
|
+
"synonyms": {
|
|
81
|
+
"editor-sidebar": [
|
|
82
|
+
"navigator",
|
|
83
|
+
"inspector",
|
|
84
|
+
"editor-rail",
|
|
85
|
+
"side-panel"
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
"tag": "editor-sidebar",
|
|
89
|
+
"tokens": {},
|
|
90
|
+
"traits": [],
|
|
91
|
+
"version": 1
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<header>
|
|
2
|
+
<div>
|
|
3
|
+
<h1>Editor Sidebar</h1>
|
|
4
|
+
<div data-actions>
|
|
5
|
+
<tag-ui size="sm">editor-sidebar</tag-ui>
|
|
6
|
+
<tag-ui size="sm" variant="ghost">JS-bearing</tag-ui>
|
|
7
|
+
</div>
|
|
8
|
+
</div>
|
|
9
|
+
<p>Module-tier editor-cluster sidebar — wraps <pane-ui resizable> rather than reimplementing drag. Cluster-distinct localStorage prefix.</p>
|
|
10
|
+
</header>
|
|
11
|
+
|
|
12
|
+
<section data-section>
|
|
13
|
+
<h2 variant="section">Role</h2>
|
|
14
|
+
<p>Per <a href="../../../../.brain/adrs/0023-bespoke-shell-tier-children.md">ADR-0023</a>, the editor cluster's bespoke family — <code><editor-shell></code> (host), <code><editor-toolbar></code>, <code><editor-canvas></code>, <code><editor-sidebar></code> (JS-bearing) + <code><editor-statusbar></code>, <code><editor-canvas-empty></code> (CSS-only). Confirms the family pattern is canonical across 3 archetypes (admin/chat/editor).</p>
|
|
15
|
+
</section>
|
|
16
|
+
|
|
17
|
+
<section data-section>
|
|
18
|
+
<h2 variant="section">Composition</h2>
|
|
19
|
+
<p>Typical placement inside <code><editor-shell></code>:</p>
|
|
20
|
+
<code-ui language="html"><editor-shell>
|
|
21
|
+
<editor-toolbar>
|
|
22
|
+
<span slot="title">Untitled.fig</span>
|
|
23
|
+
<button-ui slot="action" icon="gear"></button-ui>
|
|
24
|
+
</editor-toolbar>
|
|
25
|
+
|
|
26
|
+
<editor-sidebar slot="leading" collapsible>
|
|
27
|
+
<pane-ui resizable>
|
|
28
|
+
<header>Navigator</header>
|
|
29
|
+
<section>…layers…</section>
|
|
30
|
+
</pane-ui>
|
|
31
|
+
</editor-sidebar>
|
|
32
|
+
|
|
33
|
+
<editor-canvas>
|
|
34
|
+
<editor-canvas-empty>
|
|
35
|
+
<empty-state-ui icon="square" heading="New document" description="Drop content to begin."></empty-state-ui>
|
|
36
|
+
</editor-canvas-empty>
|
|
37
|
+
</editor-canvas>
|
|
38
|
+
|
|
39
|
+
<editor-sidebar slot="trailing" collapsible>
|
|
40
|
+
<pane-ui resizable>
|
|
41
|
+
<header>Inspector</header>
|
|
42
|
+
<section>…properties…</section>
|
|
43
|
+
</pane-ui>
|
|
44
|
+
</editor-sidebar>
|
|
45
|
+
|
|
46
|
+
<editor-statusbar>
|
|
47
|
+
<span slot="status">Saved</span>
|
|
48
|
+
<span slot="zoom">100%</span>
|
|
49
|
+
</editor-statusbar>
|
|
50
|
+
</editor-shell></code-ui>
|
|
51
|
+
</section>
|
|
52
|
+
|
|
53
|
+
<section data-section>
|
|
54
|
+
<h2 variant="section">State as attribute</h2>
|
|
55
|
+
<code-ui language="css">/* Hide all chrome in focus mode */
|
|
56
|
+
editor-shell[focus-mode] :is(editor-toolbar, editor-statusbar, editor-sidebar) {
|
|
57
|
+
display: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* Visual treatment for empty canvas */
|
|
61
|
+
editor-canvas[empty] { /* placeholder UI */ }
|
|
62
|
+
|
|
63
|
+
/* Highlight focused canvas */
|
|
64
|
+
editor-canvas[focused] { outline: 2px solid var(--a-accent); }</code-ui>
|
|
65
|
+
</section>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" data-theme="auto">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Editor Sidebar — AdiaUI</title>
|
|
7
|
+
|
|
8
|
+
<link rel="stylesheet" href="../../../web-components/styles/resets.css">
|
|
9
|
+
<link rel="stylesheet" href="../../../web-components/styles/tokens.css">
|
|
10
|
+
<link rel="stylesheet" href="../editor-shell/editor-shell.css">
|
|
11
|
+
<link rel="stylesheet" href="../../../web-components/components/code/code.css">
|
|
12
|
+
<link rel="stylesheet" href="../../../web-components/components/tag/tag.css">
|
|
13
|
+
|
|
14
|
+
<script type="module" src="../editor-shell/editor-shell.js"></script>
|
|
15
|
+
<script type="module" src="./editor-sidebar.js"></script>
|
|
16
|
+
<script type="module" src="../../../web-components/components/code/code.js"></script>
|
|
17
|
+
<script type="module" src="../../../web-components/components/tag/tag.js"></script>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
:where(html, body) { margin: 0; min-height: 100vh; background: var(--a-bg); color: var(--a-fg); font-family: var(--a-font); }
|
|
21
|
+
main { max-width: 960px; margin-inline: auto; padding: var(--a-space-6) var(--a-space-5); }
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body>
|
|
25
|
+
|
|
26
|
+
<main id="demo-root">
|
|
27
|
+
<p>Loading examples…</p>
|
|
28
|
+
</main>
|
|
29
|
+
|
|
30
|
+
<script type="module">
|
|
31
|
+
const root = document.getElementById('demo-root');
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch('./editor-sidebar.examples.html');
|
|
34
|
+
if (!res.ok) throw new Error(`fetch failed (${res.status})`);
|
|
35
|
+
root.innerHTML = await res.text();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
root.innerHTML = `<p style="color:var(--a-danger-strong);">Failed to load editor-sidebar.examples.html — ${err.message}</p>`;
|
|
38
|
+
console.error('[editor-sidebar.html]', err);
|
|
39
|
+
}
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
</body>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <editor-sidebar slot="leading|trailing" collapsible>
|
|
3
|
+
* <pane-ui resizable>
|
|
4
|
+
* <header>Navigator</header>
|
|
5
|
+
* <section>...content...</section>
|
|
6
|
+
* <footer>...actions...</footer>
|
|
7
|
+
* </pane-ui>
|
|
8
|
+
* </editor-sidebar>
|
|
9
|
+
*
|
|
10
|
+
* Module-tier editor-cluster sidebar — wraps <pane-ui resizable>
|
|
11
|
+
* (the primitive that owns drag) and adds:
|
|
12
|
+
*
|
|
13
|
+
* - [collapsed] reflected attribute (read from inner <pane-ui>
|
|
14
|
+
* width via ResizeObserver; threshold = SNAP_THRESHOLD = 96px)
|
|
15
|
+
* - localStorage persistence (adia-editor-sidebar-{name}) — preserves
|
|
16
|
+
* pane width across reloads, distinct from admin's adia-sidebar-*
|
|
17
|
+
* and chat's adia-chat-sidebar-* prefixes per cluster-namespace
|
|
18
|
+
* convention
|
|
19
|
+
* - .toggle() / .collapse() / .expand() public API (delegates to
|
|
20
|
+
* inner <pane-ui> by setting its style.width)
|
|
21
|
+
* - Slot routing — pass-through; whatever the author puts inside
|
|
22
|
+
* the inner <pane-ui> is preserved
|
|
23
|
+
*
|
|
24
|
+
* Author-supplied attributes (read once at connect):
|
|
25
|
+
* [slot="leading"|"trailing"] — required, drives localStorage
|
|
26
|
+
* namespacing
|
|
27
|
+
* [collapsible] — opts in to programmatic collapse
|
|
28
|
+
* [name="<id>"] — optional override for the
|
|
29
|
+
* localStorage key (defaults to slot)
|
|
30
|
+
*
|
|
31
|
+
* Reflected attributes:
|
|
32
|
+
* [collapsed] — set when inner pane width ≤ SNAP_THRESHOLD
|
|
33
|
+
*
|
|
34
|
+
* Events:
|
|
35
|
+
* sidebar-toggle — bubbles. detail: { name, expanded }
|
|
36
|
+
*
|
|
37
|
+
* The host (<editor-shell>) reads either <editor-sidebar> or
|
|
38
|
+
* <pane-ui data-left|data-right> via :is() selector for backwards compat.
|
|
39
|
+
*
|
|
40
|
+
* Note — this is the FIRST bespoke shell child that DELEGATES rather
|
|
41
|
+
* than DUPLICATES a primitive's behavior. Per skill principle 4, since
|
|
42
|
+
* <pane-ui resizable> already owns drag, we compose it rather than
|
|
43
|
+
* reimplement. The cluster-namespace + state-as-attribute + persistence
|
|
44
|
+
* concerns still get owned at this tier (because they're cluster-
|
|
45
|
+
* specific, not primitive-specific). See bespoke-shell-children skill
|
|
46
|
+
* §When NOT to promote for the rationale.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { UIElement } from '../../../web-components/core/element.js';
|
|
50
|
+
|
|
51
|
+
const SNAP_THRESHOLD = 96; // px — below this, sidebar is "collapsed"
|
|
52
|
+
const STORAGE_PREFIX = 'adia-editor-sidebar-';
|
|
53
|
+
|
|
54
|
+
class EditorSidebar extends UIElement {
|
|
55
|
+
static properties = {
|
|
56
|
+
collapsed: { type: Boolean, default: false, reflect: true },
|
|
57
|
+
resizing: { type: Boolean, default: false, reflect: true },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
static template = () => null;
|
|
61
|
+
|
|
62
|
+
#pane = null;
|
|
63
|
+
#ro = null;
|
|
64
|
+
#storageKey = null;
|
|
65
|
+
#onPointerDown = null;
|
|
66
|
+
#onPointerUp = null;
|
|
67
|
+
|
|
68
|
+
connected() {
|
|
69
|
+
this.#pane = this.querySelector('pane-ui');
|
|
70
|
+
if (!this.#pane) return;
|
|
71
|
+
|
|
72
|
+
// Resolve storage key
|
|
73
|
+
const slot = this.getAttribute('slot') || 'leading';
|
|
74
|
+
const name = this.getAttribute('name') || slot;
|
|
75
|
+
this.#storageKey = `${STORAGE_PREFIX}${name}`;
|
|
76
|
+
|
|
77
|
+
// Restore persisted width
|
|
78
|
+
this.#restoreWidth();
|
|
79
|
+
|
|
80
|
+
// Observe inner pane width to maintain [collapsed] reflected
|
|
81
|
+
this.#ro = new ResizeObserver(() => this.#syncCollapsed());
|
|
82
|
+
this.#ro.observe(this.#pane);
|
|
83
|
+
|
|
84
|
+
// Track resize via pointerdown on resize-handle children of the pane
|
|
85
|
+
// (pane-ui's drag-handle is internal; we listen on the pane and let
|
|
86
|
+
// pointerup on document terminate)
|
|
87
|
+
this.#onPointerDown = (e) => {
|
|
88
|
+
// Heuristic — pointerdown inside pane that targets a drag-handle
|
|
89
|
+
// (covers pane-ui's internal handle classes + [data-resize])
|
|
90
|
+
const target = e.target;
|
|
91
|
+
if (target?.matches?.('[data-resize], [class*="resize"], [class*="handle"]')) {
|
|
92
|
+
this.resizing = true;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
this.#pane.addEventListener('pointerdown', this.#onPointerDown);
|
|
96
|
+
|
|
97
|
+
this.#onPointerUp = () => {
|
|
98
|
+
if (this.resizing) {
|
|
99
|
+
this.resizing = false;
|
|
100
|
+
}
|
|
101
|
+
this.#persistWidth();
|
|
102
|
+
};
|
|
103
|
+
document.addEventListener('pointerup', this.#onPointerUp);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
disconnected() {
|
|
107
|
+
this.#ro?.disconnect();
|
|
108
|
+
this.#ro = null;
|
|
109
|
+
if (this.#onPointerDown && this.#pane) {
|
|
110
|
+
this.#pane.removeEventListener('pointerdown', this.#onPointerDown);
|
|
111
|
+
this.#onPointerDown = null;
|
|
112
|
+
}
|
|
113
|
+
if (this.#onPointerUp) {
|
|
114
|
+
document.removeEventListener('pointerup', this.#onPointerUp);
|
|
115
|
+
this.#onPointerUp = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Public API ──
|
|
120
|
+
|
|
121
|
+
toggle() {
|
|
122
|
+
if (this.collapsed) this.expand();
|
|
123
|
+
else this.collapse();
|
|
124
|
+
return this.collapsed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
collapse() {
|
|
128
|
+
if (!this.#pane) return;
|
|
129
|
+
// Preserve current expanded width before snapping so expand() can restore
|
|
130
|
+
if (!this.collapsed) {
|
|
131
|
+
this.#persistWidth();
|
|
132
|
+
}
|
|
133
|
+
this.#pane.style.width = `${SNAP_THRESHOLD}px`;
|
|
134
|
+
this.collapsed = true;
|
|
135
|
+
this.#emitToggle();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
expand() {
|
|
139
|
+
if (!this.#pane) return;
|
|
140
|
+
// Restore from storage, falling back to a usable default
|
|
141
|
+
const stored = this.#readStoredWidth();
|
|
142
|
+
const restored = stored && stored > SNAP_THRESHOLD ? stored : 240;
|
|
143
|
+
this.#pane.style.width = `${restored}px`;
|
|
144
|
+
this.collapsed = false;
|
|
145
|
+
this.#persistWidth();
|
|
146
|
+
this.#emitToggle();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Internal ──
|
|
150
|
+
|
|
151
|
+
#syncCollapsed() {
|
|
152
|
+
if (!this.#pane) return;
|
|
153
|
+
const rect = this.#pane.getBoundingClientRect();
|
|
154
|
+
this.collapsed = rect.width <= SNAP_THRESHOLD;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#emitToggle() {
|
|
158
|
+
const slot = this.getAttribute('slot') || 'leading';
|
|
159
|
+
const name = this.getAttribute('name') || slot;
|
|
160
|
+
this.dispatchEvent(new CustomEvent('sidebar-toggle', {
|
|
161
|
+
bubbles: true,
|
|
162
|
+
detail: { name, expanded: !this.collapsed },
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#persistWidth() {
|
|
167
|
+
if (!this.#pane || !this.#storageKey) return;
|
|
168
|
+
try {
|
|
169
|
+
const w = this.#pane.style.width || '';
|
|
170
|
+
if (w) localStorage.setItem(this.#storageKey, w);
|
|
171
|
+
} catch { /* private mode etc. */ }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#readStoredWidth() {
|
|
175
|
+
if (!this.#storageKey) return null;
|
|
176
|
+
try {
|
|
177
|
+
const raw = localStorage.getItem(this.#storageKey);
|
|
178
|
+
if (!raw) return null;
|
|
179
|
+
const n = parseFloat(raw);
|
|
180
|
+
return isFinite(n) ? n : null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#restoreWidth() {
|
|
187
|
+
if (!this.#pane) return;
|
|
188
|
+
const stored = this.#readStoredWidth();
|
|
189
|
+
if (stored && stored > 0) {
|
|
190
|
+
this.#pane.style.width = `${stored}px`;
|
|
191
|
+
this.collapsed = stored <= SNAP_THRESHOLD;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
customElements.define('editor-sidebar', EditorSidebar);
|
|
197
|
+
export { EditorSidebar };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import '../../../web-components/core/element.js';
|
|
3
|
+
import './editor-sidebar.js';
|
|
4
|
+
|
|
5
|
+
const tick = () => new Promise((r) => queueMicrotask(r));
|
|
6
|
+
|
|
7
|
+
function mount(html) {
|
|
8
|
+
const wrap = document.createElement('div');
|
|
9
|
+
wrap.innerHTML = html;
|
|
10
|
+
document.body.appendChild(wrap);
|
|
11
|
+
return wrap.firstElementChild;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let originalRect;
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
document.body.innerHTML = '';
|
|
17
|
+
try { localStorage.clear(); } catch {}
|
|
18
|
+
globalThis.ResizeObserver = class {
|
|
19
|
+
observe() {} unobserve() {} disconnect() {}
|
|
20
|
+
};
|
|
21
|
+
// happy-dom: stub getBoundingClientRect to derive from style.width
|
|
22
|
+
originalRect = HTMLElement.prototype.getBoundingClientRect;
|
|
23
|
+
HTMLElement.prototype.getBoundingClientRect = function () {
|
|
24
|
+
const inline = this.style?.width || '';
|
|
25
|
+
const w = parseFloat(inline) || 240;
|
|
26
|
+
return { width: w, height: 600, top: 0, left: 0, right: w, bottom: 600, x: 0, y: 0 };
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
if (originalRect) HTMLElement.prototype.getBoundingClientRect = originalRect;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('editor-sidebar', () => {
|
|
35
|
+
it('registers editor-sidebar as a custom element', () => {
|
|
36
|
+
expect(customElements.get('editor-sidebar')).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('defaults to collapsed=false on connect', () => {
|
|
40
|
+
const sb = mount('<editor-sidebar slot="leading"><pane-ui></pane-ui></editor-sidebar>');
|
|
41
|
+
expect(sb.collapsed).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('reflects [collapsed] via property assignment', async () => {
|
|
45
|
+
const sb = mount('<editor-sidebar slot="leading"><pane-ui></pane-ui></editor-sidebar>');
|
|
46
|
+
sb.collapsed = true;
|
|
47
|
+
await tick();
|
|
48
|
+
expect(sb.hasAttribute('collapsed')).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('exposes .toggle() / .collapse() / .expand() public methods', () => {
|
|
52
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
53
|
+
expect(typeof sb.toggle).toBe('function');
|
|
54
|
+
expect(typeof sb.collapse).toBe('function');
|
|
55
|
+
expect(typeof sb.expand).toBe('function');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('persists pane width to editor-namespaced localStorage key (cluster-distinct)', () => {
|
|
59
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
60
|
+
const pane = sb.querySelector('pane-ui');
|
|
61
|
+
pane.style.width = '240px';
|
|
62
|
+
sb.collapse();
|
|
63
|
+
expect(sb.collapsed).toBe(true);
|
|
64
|
+
// editor-namespaced key
|
|
65
|
+
expect(localStorage.getItem('adia-editor-sidebar-leading')).not.toBeNull();
|
|
66
|
+
// Should NOT collide with admin or chat namespaces
|
|
67
|
+
expect(localStorage.getItem('adia-sidebar-leading')).toBeNull();
|
|
68
|
+
expect(localStorage.getItem('adia-chat-sidebar-leading')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('uses [name] override for the localStorage key', () => {
|
|
72
|
+
const sb = mount('<editor-sidebar slot="leading" name="navigator" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
73
|
+
const pane = sb.querySelector('pane-ui');
|
|
74
|
+
pane.style.width = '200px';
|
|
75
|
+
sb.collapse();
|
|
76
|
+
expect(localStorage.getItem('adia-editor-sidebar-navigator')).not.toBeNull();
|
|
77
|
+
expect(localStorage.getItem('adia-editor-sidebar-leading')).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('toggle() returns the new collapsed value', () => {
|
|
81
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
82
|
+
const pane = sb.querySelector('pane-ui');
|
|
83
|
+
pane.style.width = '200px';
|
|
84
|
+
const result = sb.toggle();
|
|
85
|
+
expect(result).toBe(sb.collapsed);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('dispatches sidebar-toggle event on toggle()', () => {
|
|
89
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
90
|
+
const pane = sb.querySelector('pane-ui');
|
|
91
|
+
pane.style.width = '200px';
|
|
92
|
+
const onToggle = vi.fn();
|
|
93
|
+
sb.addEventListener('sidebar-toggle', onToggle);
|
|
94
|
+
sb.toggle();
|
|
95
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
96
|
+
expect(onToggle.mock.calls[0][0].detail).toEqual(
|
|
97
|
+
expect.objectContaining({ name: expect.any(String), expanded: expect.any(Boolean) })
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('expand() restores from stored width or defaults to 240', () => {
|
|
102
|
+
// Pre-populate stored width
|
|
103
|
+
localStorage.setItem('adia-editor-sidebar-leading', '320');
|
|
104
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
105
|
+
sb.collapse();
|
|
106
|
+
sb.expand();
|
|
107
|
+
const pane = sb.querySelector('pane-ui');
|
|
108
|
+
expect(pane.style.width).toBe('320px');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('cleanup on disconnect — removes pointerup listener', () => {
|
|
112
|
+
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
113
|
+
const removeSpy = vi.spyOn(document, 'removeEventListener');
|
|
114
|
+
sb.remove();
|
|
115
|
+
const removedTypes = removeSpy.mock.calls.map((args) => args[0]);
|
|
116
|
+
expect(removedTypes).toContain('pointerup');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('handles missing inner <pane-ui> gracefully (no crash)', () => {
|
|
120
|
+
const sb = mount('<editor-sidebar slot="leading"></editor-sidebar>');
|
|
121
|
+
// Should not throw
|
|
122
|
+
expect(() => sb.toggle()).not.toThrow();
|
|
123
|
+
expect(() => sb.collapse()).not.toThrow();
|
|
124
|
+
expect(() => sb.expand()).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('reflects [resizing] via property assignment', async () => {
|
|
128
|
+
const sb = mount('<editor-sidebar slot="leading"><pane-ui></pane-ui></editor-sidebar>');
|
|
129
|
+
sb.resizing = true;
|
|
130
|
+
await tick();
|
|
131
|
+
expect(sb.hasAttribute('resizing')).toBe(true);
|
|
132
|
+
sb.resizing = false;
|
|
133
|
+
await tick();
|
|
134
|
+
expect(sb.hasAttribute('resizing')).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('clears [resizing] on document pointerup', async () => {
|
|
138
|
+
const sb = mount('<editor-sidebar slot="leading"><pane-ui></pane-ui></editor-sidebar>');
|
|
139
|
+
sb.resizing = true;
|
|
140
|
+
await tick();
|
|
141
|
+
document.dispatchEvent(new Event('pointerup'));
|
|
142
|
+
await tick();
|
|
143
|
+
expect(sb.resizing).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|