@adia-ai/web-modules 0.8.19 → 0.8.21

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.
@@ -1,115 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import '../../../web-components/core/element.js';
3
- import './admin-command.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
- // happy-dom's <dialog> support is partial — showModal/close need to be
15
- // patched into noops that toggle the .open getter via a flag. The real
16
- // browser does this natively.
17
- function patchDialogPolyfill(dialog) {
18
- let isOpen = false;
19
- Object.defineProperty(dialog, 'open', {
20
- get: () => isOpen,
21
- set: (v) => { isOpen = !!v; },
22
- configurable: true,
23
- });
24
- dialog.showModal = function () {
25
- isOpen = true;
26
- this.dispatchEvent(new Event('toggle'));
27
- };
28
- dialog.close = function () {
29
- isOpen = false;
30
- this.dispatchEvent(new Event('close'));
31
- };
32
- }
33
-
34
- beforeEach(() => {
35
- document.body.innerHTML = '';
36
- });
37
-
38
- describe('admin-command', () => {
39
- it('registers admin-command as a custom element', () => {
40
- expect(customElements.get('admin-command')).toBeDefined();
41
- });
42
-
43
- it('defaults to open=false, shortcut="both"', () => {
44
- const cmd = mount('<admin-command></admin-command>');
45
- expect(cmd.open).toBe(false);
46
- expect(cmd.shortcut).toBe('both');
47
- });
48
-
49
- it('exposes .show() / .hide() / .toggle() public methods', () => {
50
- const cmd = mount('<admin-command></admin-command>');
51
- expect(typeof cmd.show).toBe('function');
52
- expect(typeof cmd.hide).toBe('function');
53
- expect(typeof cmd.toggle).toBe('function');
54
- });
55
-
56
- it('reflects [open] when .show() is called', async () => {
57
- const cmd = mount('<admin-command></admin-command>');
58
- const dialog = cmd.shadowRoot?.querySelector('dialog') ?? cmd.querySelector('dialog');
59
- if (dialog) patchDialogPolyfill(dialog);
60
- cmd.show();
61
- await tick();
62
- expect(cmd.open).toBe(true);
63
- expect(cmd.hasAttribute('open')).toBe(true);
64
- });
65
-
66
- it('clears [open] when .hide() is called', async () => {
67
- const cmd = mount('<admin-command></admin-command>');
68
- const dialog = cmd.shadowRoot?.querySelector('dialog') ?? cmd.querySelector('dialog');
69
- if (dialog) patchDialogPolyfill(dialog);
70
- cmd.show();
71
- await tick();
72
- cmd.hide();
73
- await tick();
74
- expect(cmd.open).toBe(false);
75
- expect(cmd.hasAttribute('open')).toBe(false);
76
- });
77
-
78
- it('toggle() flips open state and returns the new value', async () => {
79
- const cmd = mount('<admin-command></admin-command>');
80
- const dialog = cmd.shadowRoot?.querySelector('dialog') ?? cmd.querySelector('dialog');
81
- if (dialog) patchDialogPolyfill(dialog);
82
- const after1 = cmd.toggle();
83
- await tick();
84
- expect(after1).toBe(true);
85
- const after2 = cmd.toggle();
86
- await tick();
87
- expect(after2).toBe(false);
88
- });
89
-
90
- it('removes Cmd+K listener on disconnect', () => {
91
- const cmd = mount('<admin-command></admin-command>');
92
- const removeSpy = vi.spyOn(document, 'removeEventListener');
93
- cmd.remove();
94
- const removedTypes = removeSpy.mock.calls.map((args) => args[0]);
95
- expect(removedTypes).toContain('keydown');
96
- });
97
-
98
- it('honors [no-shortcut] — does not toggle on Cmd+K', async () => {
99
- // Verify behavior, not listener registration (happy-dom timing makes
100
- // spy-based tests unreliable for synchronous custom-element lifecycle).
101
- const cmd = mount('<admin-command no-shortcut></admin-command>');
102
- const dialog = cmd.querySelector(':scope > dialog');
103
- if (dialog) patchDialogPolyfill(dialog);
104
- const event = new KeyboardEvent('keydown', { key: 'k', metaKey: true });
105
- document.dispatchEvent(event);
106
- await tick();
107
- expect(cmd.open).toBe(false);
108
- cmd.remove();
109
- });
110
-
111
- it('accepts shortcut="cmd+k"', () => {
112
- const cmd = mount('<admin-command shortcut="cmd+k"></admin-command>');
113
- expect(cmd.shortcut).toBe('cmd+k');
114
- });
115
- });
@@ -1,185 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { readFileSync } from 'node:fs';
3
- import { fileURLToPath } from 'node:url';
4
- import { dirname, resolve } from 'node:path';
5
-
6
- const __dirname = dirname(fileURLToPath(import.meta.url));
7
- const bespokeCSS = readFileSync(resolve(__dirname, 'css/admin-shell.bespoke.css'), 'utf8');
8
- const collapsedCSS = readFileSync(resolve(__dirname, 'css/admin-shell.collapsed.css'), 'utf8');
9
-
10
- // ── Helpers ────────────────────────────────────────────────────────
11
- function mount(html) {
12
- document.body.innerHTML = html;
13
- return document.body.firstElementChild;
14
- }
15
-
16
- beforeEach(() => {
17
- document.body.innerHTML = '';
18
- });
19
-
20
- // ── Regression guards ─────────────────────────────────────────────
21
- //
22
- // These tests document substrate selector decisions that have been
23
- // regressed and re-fixed at least once each. They run against the raw
24
- // CSS file content + DOM `element.matches()` rather than computed style,
25
- // because happy-dom doesn't fully resolve external stylesheet cascade.
26
- // The trade-off: we don't assert that the rule WAS applied, but we DO
27
- // assert that the rule's selector REACHES the element it's meant to
28
- // style — which is the part that broke in v0.6.16.
29
-
30
- describe('admin-shell.bespoke.css — selector reachability', () => {
31
- it('v0.6.16 fix: admin-page rule uses descendant combinator (not >)', () => {
32
- // The previous selector `admin-scroll > admin-page` broke when a
33
- // router (e.g. <router-ui>) sat between <admin-scroll> and
34
- // <admin-page>. The fix uses :is() + descendant combinator so any
35
- // depth of intermediate wrapping still matches.
36
- expect(bespokeCSS).toMatch(/:is\(admin-scroll,\s*admin-content\)\s+admin-page\s*\{/);
37
- // Negative assertion: the broken direct-child form should not be
38
- // present any more (catches accidental reverts).
39
- expect(bespokeCSS).not.toMatch(/^admin-scroll\s*>\s*admin-page\s*,/m);
40
- expect(bespokeCSS).not.toMatch(/^admin-content\s*>\s*admin-page\s*\{/m);
41
- });
42
-
43
- it('admin-page rule reaches admin-page inside <router-ui>', () => {
44
- // Synthesize the docs-site DOM shape that hit the v0.6.16 bug.
45
- // The selector under test must match <admin-page> via descendant
46
- // combinator even when <router-ui> sits between.
47
- mount(`
48
- <admin-shell>
49
- <admin-content>
50
- <admin-scroll>
51
- <router-ui id="router">
52
- <admin-page id="page"></admin-page>
53
- </router-ui>
54
- </admin-scroll>
55
- </admin-content>
56
- </admin-shell>
57
- `);
58
- const page = document.getElementById('page');
59
- expect(page).toBeTruthy();
60
- // Direct-child match should fail (router-ui sits between)
61
- expect(page.matches('admin-scroll > admin-page')).toBe(false);
62
- expect(page.matches('admin-content > admin-page')).toBe(false);
63
- // Descendant match must succeed (this is what v0.6.17 introduced)
64
- expect(page.matches(':is(admin-scroll, admin-content) admin-page')).toBe(true);
65
- });
66
-
67
- it('admin-page rule still reaches admin-page as direct child', () => {
68
- // Canonical playground/example layout — no router wrapper.
69
- // The descendant combinator must still match this case.
70
- mount(`
71
- <admin-shell>
72
- <admin-content>
73
- <admin-scroll>
74
- <admin-page id="page"></admin-page>
75
- </admin-scroll>
76
- </admin-content>
77
- </admin-shell>
78
- `);
79
- const page = document.getElementById('page');
80
- expect(page.matches(':is(admin-scroll, admin-content) admin-page')).toBe(true);
81
- });
82
- });
83
-
84
- describe('admin-shell.collapsed.css — vanilla HTML fallback (v0.6.17)', () => {
85
- it('contains the vanilla-HTML fallback block', () => {
86
- // v0.6.17 added rules covering <button class="nav-item"> etc. so
87
- // text labels don't overflow the 48px rail when the consumer doesn't
88
- // use AdiaUI <nav-item-ui> primitives.
89
- expect(collapsedCSS).toMatch(/Vanilla-HTML fallback/);
90
- // v0.6.20 (FEEDBACK-38 addendum): the heading-hide rule narrowed from a
91
- // blanket `[slot="heading"]` to plain-text headings + headings without
92
- // slotted children, so composed wrappers (<admin-entity-item slot="heading">)
93
- // survive collapse. The rule still resolves to `display: none`.
94
- expect(collapsedCSS).toMatch(/\[slot="heading"\][^{]*\{\s*display:\s*none/);
95
- expect(collapsedCSS).toMatch(/\[slot="heading"\]:not\(:has\(>\s*\[slot\]\)\)/);
96
- expect(collapsedCSS).toMatch(/button\.nav-item/);
97
- expect(collapsedCSS).toMatch(/text-indent:\s*-9999px/);
98
- });
99
-
100
- it('preserves icon visibility inside vanilla nav buttons', () => {
101
- // text-indent:-9999px on the parent must be undone on the icon
102
- // child, otherwise the icon disappears with the text. Both rules
103
- // must be present in the file.
104
- expect(collapsedCSS).toMatch(/button\.nav-item\s*>\s*icon-ui[\s\S]*?text-indent:\s*0/);
105
- });
106
-
107
- it('fallback rules sit inside the @container sidebar query', () => {
108
- // The vanilla-HTML fallback must be scoped to the collapsed state
109
- // (max-width: 96px container query). Outside that scope, the
110
- // text-indent: -9999px clip would also break the expanded state.
111
- const containerMatch = collapsedCSS.match(/@container\s+sidebar\s*\(max-width:\s*96px\)\s*\{([\s\S]+)\}/);
112
- expect(containerMatch).toBeTruthy();
113
- const inside = containerMatch[1];
114
- expect(inside).toMatch(/Vanilla-HTML fallback/);
115
- expect(inside).toMatch(/text-indent:\s*-9999px/);
116
- });
117
- });
118
-
119
- // ── FB-55 — runtime warn when AdminSidebar / AdminCommand unregistered ──
120
- //
121
- // Per-component import path (`@adia-ai/web-modules/shell/admin-shell`)
122
- // registers ONLY AdminShell. <admin-sidebar> renders (CSS targets by
123
- // tag) but is an unupgraded HTMLElement with no .toggle(). The
124
- // optional chain `sidebar?.toggle?.()` previously swallowed the no-op
125
- // silently — zero diagnostic. v0.6.35 adds a one-shot console.warn at
126
- // toggle-time when the sidebar is found but not upgraded.
127
- //
128
- // These tests import ONLY AdminShell (not AdminSidebar), simulate the
129
- // per-component-path failure mode, and assert the warn fires + dedups
130
- // per element.
131
-
132
- describe('admin-shell — FB-55 unregistered-element warn', () => {
133
- it('logs warn when [data-sidebar-toggle] clicked and admin-sidebar not registered', async () => {
134
- // AdminShell only — AdminSidebar deliberately NOT imported.
135
- await import('./admin-shell.js');
136
- const warns = [];
137
- const origWarn = console.warn;
138
- console.warn = (...args) => warns.push(args.join(' '));
139
- try {
140
- const shell = mount(
141
- '<admin-shell>' +
142
- '<admin-sidebar slot="leading"></admin-sidebar>' +
143
- '<button data-sidebar-toggle="leading">☰</button>' +
144
- '</admin-shell>'
145
- );
146
- // Allow the connect-time wiring to settle.
147
- await new Promise((r) => queueMicrotask(r));
148
- const btn = shell.querySelector('[data-sidebar-toggle]');
149
- btn.click();
150
- expect(warns.length).toBe(1);
151
- expect(warns[0]).toMatch(/admin-sidebar.*slot="leading".*not registered/i);
152
- expect(warns[0]).toMatch(/Sidebar toggle will not work/);
153
- expect(warns[0]).toMatch(/cluster barrel/);
154
- // Second click — dedup, no additional warn for the same offender.
155
- btn.click();
156
- expect(warns.length).toBe(1);
157
- } finally {
158
- console.warn = origWarn;
159
- }
160
- });
161
-
162
- it('does NOT log when admin-sidebar IS registered (upgraded element)', async () => {
163
- // Import the sidebar class — element is now upgraded with .toggle().
164
- await import('../admin-sidebar/admin-sidebar.js');
165
- await import('./admin-shell.js');
166
- const warns = [];
167
- const origWarn = console.warn;
168
- console.warn = (...args) => warns.push(args.join(' '));
169
- try {
170
- const shell = mount(
171
- '<admin-shell>' +
172
- '<admin-sidebar slot="leading"></admin-sidebar>' +
173
- '<button data-sidebar-toggle="leading">☰</button>' +
174
- '</admin-shell>'
175
- );
176
- await new Promise((r) => queueMicrotask(r));
177
- shell.querySelector('[data-sidebar-toggle]').click();
178
- // No warn — sidebar.toggle() exists and runs cleanly.
179
- const fb55Warns = warns.filter((w) => /FB-55|not registered/.test(w));
180
- expect(fb55Warns.length).toBe(0);
181
- } finally {
182
- console.warn = origWarn;
183
- }
184
- });
185
- });
@@ -1,173 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import '../../../web-components/core/element.js';
3
- import './admin-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
- // happy-dom doesn't ship a working ResizeObserver out-of-the-box, and
15
- // returns zero from getBoundingClientRect (no layout engine). Patch both
16
- // in beforeEach so connected() doesn't immediately mark every sidebar as
17
- // collapsed (width <= 96).
18
- let originalRect;
19
- beforeEach(() => {
20
- document.body.innerHTML = '';
21
- try { localStorage.clear(); } catch {}
22
- globalThis.ResizeObserver = class {
23
- observe() {}
24
- unobserve() {}
25
- disconnect() {}
26
- };
27
- // Mock getBoundingClientRect to derive width from inline style (or 240 default)
28
- originalRect = HTMLElement.prototype.getBoundingClientRect;
29
- HTMLElement.prototype.getBoundingClientRect = function () {
30
- const inline = this.style?.width || '';
31
- const w = parseFloat(inline) || 240;
32
- return { width: w, height: 600, top: 0, left: 0, right: w, bottom: 600, x: 0, y: 0 };
33
- };
34
- });
35
-
36
- afterEach(() => {
37
- if (originalRect) HTMLElement.prototype.getBoundingClientRect = originalRect;
38
- });
39
-
40
- describe('admin-sidebar', () => {
41
- it('registers admin-sidebar as a custom element', () => {
42
- expect(customElements.get('admin-sidebar')).toBeDefined();
43
- });
44
-
45
- it('defaults to collapsed=false on connect', () => {
46
- const sb = mount('<admin-sidebar slot="leading"></admin-sidebar>');
47
- expect(sb.collapsed).toBe(false);
48
- });
49
-
50
- it('reflects [collapsed] via property assignment', async () => {
51
- const sb = mount('<admin-sidebar slot="leading"></admin-sidebar>');
52
- sb.collapsed = true;
53
- await tick();
54
- expect(sb.hasAttribute('collapsed')).toBe(true);
55
- });
56
-
57
- it('reflects [resizing] via property assignment', async () => {
58
- const sb = mount('<admin-sidebar slot="leading"></admin-sidebar>');
59
- sb.resizing = true;
60
- await tick();
61
- expect(sb.hasAttribute('resizing')).toBe(true);
62
- });
63
-
64
- it('exposes .toggle() / .collapse() / .expand() public methods', () => {
65
- const sb = mount('<admin-sidebar slot="leading" collapsible></admin-sidebar>');
66
- expect(typeof sb.toggle).toBe('function');
67
- expect(typeof sb.collapse).toBe('function');
68
- expect(typeof sb.expand).toBe('function');
69
- });
70
-
71
- it('persists width to localStorage on collapse, restores on connect', async () => {
72
- // First mount: set a width, collapse, persisted to localStorage
73
- const sb1 = mount('<admin-sidebar slot="leading" collapsible></admin-sidebar>');
74
- sb1.style.width = '240px';
75
- // happy-dom doesn't compute layout; mock getBoundingClientRect to return our set width
76
- sb1.getBoundingClientRect = () => ({ width: 240 });
77
- sb1.collapse();
78
- expect(sb1.collapsed).toBe(true);
79
- // localStorage should hold either the floor or the previous width
80
- const stored = localStorage.getItem('adia-sidebar-leading');
81
- expect(stored).not.toBeNull();
82
- });
83
-
84
- it('uses [name] override for the localStorage key', () => {
85
- const sb = mount('<admin-sidebar slot="leading" name="custom-id" collapsible></admin-sidebar>');
86
- sb.getBoundingClientRect = () => ({ width: 200 });
87
- sb.style.width = '200px';
88
- sb.collapse();
89
- expect(localStorage.getItem('adia-sidebar-custom-id')).not.toBeNull();
90
- expect(localStorage.getItem('adia-sidebar-leading')).toBeNull();
91
- });
92
-
93
- it('toggle() returns the new collapsed value', () => {
94
- const sb = mount('<admin-sidebar slot="leading" collapsible></admin-sidebar>');
95
- sb.getBoundingClientRect = () => ({ width: 200 });
96
- sb.style.width = '200px';
97
- const result = sb.toggle();
98
- expect(result).toBe(sb.collapsed);
99
- });
100
-
101
- it('dispatches sidebar-toggle event on toggle()', () => {
102
- const sb = mount('<admin-sidebar slot="leading" collapsible></admin-sidebar>');
103
- sb.getBoundingClientRect = () => ({ width: 200 });
104
- sb.style.width = '200px';
105
- const onToggle = vi.fn();
106
- sb.addEventListener('sidebar-toggle', onToggle);
107
- sb.toggle();
108
- expect(onToggle).toHaveBeenCalledTimes(1);
109
- expect(onToggle.mock.calls[0][0].detail).toEqual(
110
- expect.objectContaining({ name: expect.any(String), expanded: expect.any(Boolean) })
111
- );
112
- });
113
-
114
- it('cleans up resize handlers on disconnect (no zombie listeners)', () => {
115
- const sb = mount('<admin-sidebar slot="leading" resizable><div data-resize></div></admin-sidebar>');
116
- const handle = sb.querySelector('[data-resize]');
117
- const removeSpy = vi.spyOn(handle, 'removeEventListener');
118
- sb.remove();
119
- // disconnected() runs cleanup; pointerdown listener should have been removed
120
- const removedTypes = removeSpy.mock.calls.map((args) => args[0]);
121
- expect(removedTypes).toContain('pointerdown');
122
- });
123
-
124
- // gh#286 — a zero rect (linkedom/SSR shims have no layout engine; a
125
- // browser can also connect an element before its first layout, e.g.
126
- // inside a display:none ancestor) must NOT snap the sidebar collapsed.
127
- describe('gh#286 — zero-rect connect does not force collapse', () => {
128
- it('stays at the connect() default (expanded) when the rect is 0×0', () => {
129
- HTMLElement.prototype.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 });
130
- const sb = mount('<admin-sidebar slot="leading"></admin-sidebar>');
131
- expect(sb.collapsed).toBe(false);
132
- });
133
-
134
- it('keeps a persisted expanded width instead of overriding it with a bogus collapse', () => {
135
- // First mount at a real expanded width — persists 240px.
136
- const sb1 = mount('<admin-sidebar slot="leading" collapsible></admin-sidebar>');
137
- sb1.getBoundingClientRect = () => ({ width: 240 });
138
- sb1.style.width = '240px';
139
- sb1.collapse();
140
- sb1.expand();
141
- sb1.remove();
142
-
143
- // Re-mount under a zero-rect environment (the SSR case) — the
144
- // restored width from storage should survive; connected() must not
145
- // overwrite it back to collapsed just because the rect read 0.
146
- HTMLElement.prototype.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 });
147
- const sb2 = mount('<admin-sidebar slot="leading" collapsible></admin-sidebar>');
148
- expect(sb2.collapsed).toBe(false);
149
- });
150
-
151
- it('the ResizeObserver first tick self-corrects once real layout exists', () => {
152
- let observedCallback;
153
- globalThis.ResizeObserver = class {
154
- constructor(cb) { observedCallback = cb; }
155
- observe() {}
156
- unobserve() {}
157
- disconnect() {}
158
- };
159
- HTMLElement.prototype.getBoundingClientRect = () => ({ width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0 });
160
- const sb = mount('<admin-sidebar slot="leading"></admin-sidebar>');
161
- expect(sb.collapsed).toBe(false); // still unresolved right after connect
162
-
163
- // Simulate the observer's first real tick (e.g. SSR hydration's
164
- // first client paint) reporting an actually-narrow layout.
165
- observedCallback([{ contentBoxSize: [{ inlineSize: 60 }] }]);
166
- expect(sb.collapsed).toBe(true);
167
-
168
- // And the inverse — a real wide layout expands it.
169
- observedCallback([{ contentBoxSize: [{ inlineSize: 240 }] }]);
170
- expect(sb.collapsed).toBe(false);
171
- });
172
- });
173
- });
@@ -1,127 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import './embed-shell.js'; // registers <embed-shell>
3
-
4
- // A minimal consumer shape: one [app] surface with two [opens] triggers, plus
5
- // two named [panel] surfaces (one carrying a [close] trigger). The shell owns
6
- // layout, not content — divs stand in for the real consumer surfaces.
7
- function mount() {
8
- document.body.innerHTML = `
9
- <embed-shell>
10
- <div app>
11
- <button id="open-chat" opens="chat">chat</button>
12
- <button id="open-settings" opens="settings">settings</button>
13
- </div>
14
- <div panel="chat"><button id="close" close>x</button></div>
15
- <div panel="settings"></div>
16
- </embed-shell>`;
17
- return document.body.querySelector('embed-shell');
18
- }
19
-
20
- beforeEach(() => {
21
- document.body.innerHTML = '';
22
- });
23
-
24
- // ── open / close / toggle ─────────────────────────────────────────
25
- describe('embed-shell — open / close / toggle API', () => {
26
- it('open(name) reflects [panel] on the shell + sets [active] on the matching surface', () => {
27
- const shell = mount();
28
- shell.open('chat');
29
- expect(shell.getAttribute('panel')).toBe('chat');
30
- expect(shell.querySelector('[panel="chat"]').hasAttribute('active')).toBe(true);
31
- expect(shell.panel).toBe('chat');
32
- });
33
-
34
- it('open(name) switches the active panel — only one [active] at a time', () => {
35
- const shell = mount();
36
- shell.open('chat');
37
- shell.open('settings');
38
- expect(shell.getAttribute('panel')).toBe('settings');
39
- expect(shell.querySelector('[panel="chat"]').hasAttribute('active')).toBe(false);
40
- expect(shell.querySelector('[panel="settings"]').hasAttribute('active')).toBe(true);
41
- });
42
-
43
- it('open() with no matching [panel] is a no-op (consumer must provide the surface)', () => {
44
- const shell = mount();
45
- shell.open('nope');
46
- expect(shell.hasAttribute('panel')).toBe(false);
47
- });
48
-
49
- it('open() ignores an empty name', () => {
50
- const shell = mount();
51
- shell.open('');
52
- expect(shell.hasAttribute('panel')).toBe(false);
53
- });
54
-
55
- it('close() clears [panel] + [active]; .panel reads ""', () => {
56
- const shell = mount();
57
- shell.open('chat');
58
- shell.close();
59
- expect(shell.hasAttribute('panel')).toBe(false);
60
- expect(shell.querySelector('[panel="chat"]').hasAttribute('active')).toBe(false);
61
- expect(shell.panel).toBe('');
62
- });
63
-
64
- it('toggle() opens when closed, closes when the same panel is already open', () => {
65
- const shell = mount();
66
- shell.toggle('chat');
67
- expect(shell.panel).toBe('chat');
68
- shell.toggle('chat');
69
- expect(shell.panel).toBe('');
70
- });
71
-
72
- it('emits embed:change with the new panel name on open, "" on close', () => {
73
- const shell = mount();
74
- const seen = [];
75
- shell.addEventListener('embed:change', (e) => seen.push(e.detail.panel));
76
- shell.open('chat');
77
- shell.close();
78
- expect(seen).toEqual(['chat', '']);
79
- });
80
- });
81
-
82
- // ── delegated triggers + inbound events ───────────────────────────
83
- describe('embed-shell — delegated triggers + events', () => {
84
- it('[opens] click toggles the named panel', () => {
85
- const shell = mount();
86
- shell.querySelector('#open-chat').click();
87
- expect(shell.panel).toBe('chat');
88
- shell.querySelector('#open-chat').click(); // same trigger toggles it back off
89
- expect(shell.panel).toBe('');
90
- });
91
-
92
- it('[opens] switches directly between panels', () => {
93
- const shell = mount();
94
- shell.querySelector('#open-chat').click();
95
- shell.querySelector('#open-settings').click();
96
- expect(shell.panel).toBe('settings');
97
- });
98
-
99
- it('[close] click dismisses the open panel', () => {
100
- const shell = mount();
101
- shell.open('chat');
102
- shell.querySelector('#close').click();
103
- expect(shell.panel).toBe('');
104
- });
105
-
106
- it('embed:open event opens/toggles a panel', () => {
107
- const shell = mount();
108
- shell.querySelector('[app]').dispatchEvent(
109
- new CustomEvent('embed:open', { detail: { panel: 'settings' }, bubbles: true }),
110
- );
111
- expect(shell.panel).toBe('settings');
112
- });
113
-
114
- it('Escape closes the open panel', () => {
115
- const shell = mount();
116
- shell.open('chat');
117
- shell.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
118
- expect(shell.panel).toBe('');
119
- });
120
-
121
- it('Escape is a no-op when no panel is open', () => {
122
- const shell = mount();
123
- // Should not throw; panel stays closed.
124
- shell.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
125
- expect(shell.panel).toBe('');
126
- });
127
- });
@@ -1,83 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import '../../../web-components/core/element.js';
3
- import './simple-shell.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
- beforeEach(() => {
15
- document.body.innerHTML = '';
16
- });
17
-
18
- describe('simple-shell', () => {
19
- it('registers simple-shell as a custom element', () => {
20
- expect(customElements.get('simple-shell')).toBeDefined();
21
- });
22
-
23
- it('defaults to centered=false and full-bleed=false', () => {
24
- const el = mount('<simple-shell></simple-shell>');
25
- expect(el.hasAttribute('centered')).toBe(false);
26
- expect(el.hasAttribute('full-bleed')).toBe(false);
27
- });
28
-
29
- it('honors initial [centered] attribute on connect', () => {
30
- const el = mount('<simple-shell centered></simple-shell>');
31
- expect(el.centered).toBe(true);
32
- });
33
-
34
- it('honors initial [full-bleed] attribute on connect', () => {
35
- const el = mount('<simple-shell full-bleed></simple-shell>');
36
- expect(el['full-bleed']).toBe(true);
37
- });
38
-
39
- it('reflects [centered] via property assignment', async () => {
40
- const el = mount('<simple-shell></simple-shell>');
41
- el.centered = true;
42
- await tick();
43
- expect(el.hasAttribute('centered')).toBe(true);
44
- });
45
-
46
- it('reflects [full-bleed] via property assignment', async () => {
47
- const el = mount('<simple-shell></simple-shell>');
48
- el['full-bleed'] = true;
49
- await tick();
50
- expect(el.hasAttribute('full-bleed')).toBe(true);
51
- });
52
-
53
- it('removes [centered] when set to false', async () => {
54
- const el = mount('<simple-shell centered></simple-shell>');
55
- el.centered = false;
56
- await tick();
57
- expect(el.hasAttribute('centered')).toBe(false);
58
- });
59
-
60
- it('stamps no HTML (behavior-only orchestrator)', () => {
61
- const el = mount('<simple-shell></simple-shell>');
62
- // Empty content + nothing stamped by the host
63
- expect(el.children.length).toBe(0);
64
- });
65
-
66
- it('preserves authored slotted children', () => {
67
- const el = mount(`
68
- <simple-shell>
69
- <simple-hero><h1 slot="heading">Hello</h1></simple-hero>
70
- <simple-content><p>Body</p></simple-content>
71
- </simple-shell>
72
- `);
73
- expect(el.querySelector('simple-hero')).toBeTruthy();
74
- expect(el.querySelector('simple-content')).toBeTruthy();
75
- expect(el.querySelector('[slot="heading"]')).toBeTruthy();
76
- });
77
-
78
- it('supports both [centered] and [full-bleed] simultaneously', () => {
79
- const el = mount('<simple-shell centered full-bleed></simple-shell>');
80
- expect(el.centered).toBe(true);
81
- expect(el['full-bleed']).toBe(true);
82
- });
83
- });