@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/chat/chat-shell/chat-shell.js +28 -40
  3. package/chat/chat-shell/css/chat-shell.empty.css +3 -3
  4. package/chat/chat-shell/css/chat-shell.layout.css +2 -2
  5. package/editor/editor-canvas/editor-canvas.a2ui.json +87 -0
  6. package/editor/editor-canvas/editor-canvas.examples.html +65 -0
  7. package/editor/editor-canvas/editor-canvas.html +43 -0
  8. package/editor/editor-canvas/editor-canvas.js +103 -0
  9. package/editor/editor-canvas/editor-canvas.test.js +100 -0
  10. package/editor/editor-canvas/editor-canvas.yaml +88 -0
  11. package/editor/editor-canvas-empty/editor-canvas-empty.a2ui.json +69 -0
  12. package/editor/editor-canvas-empty/editor-canvas-empty.examples.html +65 -0
  13. package/editor/editor-canvas-empty/editor-canvas-empty.html +42 -0
  14. package/editor/editor-canvas-empty/editor-canvas-empty.yaml +56 -0
  15. package/editor/editor-shell/css/editor-shell.bespoke.css +237 -0
  16. package/editor/editor-shell/css/editor-shell.layout.css +6 -6
  17. package/editor/editor-shell/editor-shell.css +1 -0
  18. package/editor/editor-shell/editor-shell.js +87 -30
  19. package/editor/editor-sidebar/editor-sidebar.a2ui.json +93 -0
  20. package/editor/editor-sidebar/editor-sidebar.examples.html +65 -0
  21. package/editor/editor-sidebar/editor-sidebar.html +43 -0
  22. package/editor/editor-sidebar/editor-sidebar.js +197 -0
  23. package/editor/editor-sidebar/editor-sidebar.test.js +145 -0
  24. package/editor/editor-sidebar/editor-sidebar.yaml +91 -0
  25. package/editor/editor-statusbar/editor-statusbar.a2ui.json +76 -0
  26. package/editor/editor-statusbar/editor-statusbar.examples.html +65 -0
  27. package/editor/editor-statusbar/editor-statusbar.html +42 -0
  28. package/editor/editor-statusbar/editor-statusbar.yaml +57 -0
  29. package/editor/editor-toolbar/editor-toolbar.a2ui.json +96 -0
  30. package/editor/editor-toolbar/editor-toolbar.examples.html +65 -0
  31. package/editor/editor-toolbar/editor-toolbar.html +43 -0
  32. package/editor/editor-toolbar/editor-toolbar.js +58 -0
  33. package/editor/editor-toolbar/editor-toolbar.test.js +99 -0
  34. package/editor/editor-toolbar/editor-toolbar.yaml +81 -0
  35. package/editor/index.js +3 -0
  36. package/package.json +4 -4
  37. package/shell/admin-shell/admin-shell.js +27 -243
  38. package/shell/admin-shell/css/admin-shell.bespoke.css +22 -26
  39. package/shell/admin-shell/css/admin-shell.main.css +2 -2
  40. package/shell/admin-shell/css/admin-shell.shell.css +2 -2
  41. 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> — Editor layout pattern.
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 tool UIs:
16
+ * Behavior-only orchestrator for design-tool UIs:
7
17
  * topbar, navigator pane, center canvas, inspector pane, bottombar.
8
18
  *
9
- * Structure:
10
- * <editor-shell>
11
- * <header>
12
- * <span data-title>Editor</span>
13
- * <span data-spacer></span>
14
- * <button-ui icon="gear" variant="ghost" size="sm"></button-ui>
15
- * </header>
16
- * <div data-editor-body>
17
- * <pane-ui data-left resizable>
18
- * <header>Navigator</header>
19
- * <section>...tree, layers...</section>
20
- * </pane-ui>
21
- * <div data-canvas>
22
- * ...canvas content...
23
- * </div>
24
- * <pane-ui data-right resizable>
25
- * <header>Inspector</header>
26
- * <section>...form fields...</section>
27
- * <footer>...actions...</footer>
28
- * </pane-ui>
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
- // Wire select options if present
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>&lt;editor-shell&gt;</code> (host), <code>&lt;editor-toolbar&gt;</code>, <code>&lt;editor-canvas&gt;</code>, <code>&lt;editor-sidebar&gt;</code> (JS-bearing) + <code>&lt;editor-statusbar&gt;</code>, <code>&lt;editor-canvas-empty&gt;</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>&lt;editor-shell&gt;</code>:</p>
20
+ <code-ui language="html">&lt;editor-shell&gt;
21
+ &lt;editor-toolbar&gt;
22
+ &lt;span slot="title"&gt;Untitled.fig&lt;/span&gt;
23
+ &lt;button-ui slot="action" icon="gear"&gt;&lt;/button-ui&gt;
24
+ &lt;/editor-toolbar&gt;
25
+
26
+ &lt;editor-sidebar slot="leading" collapsible&gt;
27
+ &lt;pane-ui resizable&gt;
28
+ &lt;header&gt;Navigator&lt;/header&gt;
29
+ &lt;section&gt;…layers…&lt;/section&gt;
30
+ &lt;/pane-ui&gt;
31
+ &lt;/editor-sidebar&gt;
32
+
33
+ &lt;editor-canvas&gt;
34
+ &lt;editor-canvas-empty&gt;
35
+ &lt;empty-state-ui icon="square" heading="New document" description="Drop content to begin."&gt;&lt;/empty-state-ui&gt;
36
+ &lt;/editor-canvas-empty&gt;
37
+ &lt;/editor-canvas&gt;
38
+
39
+ &lt;editor-sidebar slot="trailing" collapsible&gt;
40
+ &lt;pane-ui resizable&gt;
41
+ &lt;header&gt;Inspector&lt;/header&gt;
42
+ &lt;section&gt;…properties…&lt;/section&gt;
43
+ &lt;/pane-ui&gt;
44
+ &lt;/editor-sidebar&gt;
45
+
46
+ &lt;editor-statusbar&gt;
47
+ &lt;span slot="status"&gt;Saved&lt;/span&gt;
48
+ &lt;span slot="zoom"&gt;100%&lt;/span&gt;
49
+ &lt;/editor-statusbar&gt;
50
+ &lt;/editor-shell&gt;</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
+ });