@adia-ai/web-modules 0.7.9 → 0.7.11

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.
@@ -0,0 +1,92 @@
1
+ /**
2
+ * <embed-shell> — embedded multi-surface shell.
3
+ *
4
+ * Composes a primary `[app]` surface with secondary `[panel="<name>"]` surfaces
5
+ * the consumer provides, and arranges them responsively. Peer of admin-shell /
6
+ * editor-shell / chat-shell. Light-DOM (P1). Content-agnostic: it orchestrates
7
+ * whatever `[app]` + `[panel]` children it's given — it owns layout, not content.
8
+ *
9
+ * Contract (clean attributes — ADR-0024, no data-*):
10
+ * <embed-shell>
11
+ * <x app>…</x> ← the primary surface (one)
12
+ * <y panel="chat">…</y> ← a secondary surface (any number)
13
+ * <z panel="settings">…</z>
14
+ * </embed-shell>
15
+ * …anywhere inside: <button opens="chat"> ← trigger (delegated)
16
+ * <button close> ← dismiss the open panel
17
+ *
18
+ * State (reflected, ADR-0023):
19
+ * embed-shell[panel="chat"] ← which panel is open ('' / absent = none)
20
+ * <y panel="chat" active> ← the open panel carries [active] (CSS hook)
21
+ *
22
+ * Events:
23
+ * embed:open (in) detail:{panel} ← request to open/toggle a panel
24
+ * embed:change(out) detail:{panel} ← emitted after the open panel changes
25
+ *
26
+ * Layout lives in embed-shell.css: ≥760px the open panel is a 50/50 column; <760px
27
+ * it's a cover sheet (translateY — identity at rest, so popovers inside still anchor).
28
+ *
29
+ * Extends UIElement (the shell base) so the declarative trait pattern works:
30
+ * `<embed-shell traits="resizable">` applies the real `resizable` trait (drag any
31
+ * edge to resize + a `resize-end` event), not a CSS stand-in. The trait is imported
32
+ * here so it's registered for declarative lookup.
33
+ */
34
+ import { UIElement } from '../../../web-components/core/element.js';
35
+ import '../../../web-components/traits/resizable/resizable.js'; // register `resizable` for traits="resizable"
36
+
37
+ class EmbedShell extends UIElement {
38
+ static template = () => null; // behavior-only, light-DOM — keep the consumer's children
39
+
40
+ connected() {
41
+ this.addEventListener('click', this.#onClick);
42
+ this.addEventListener('embed:open', this.#onEmbedOpen);
43
+ this.addEventListener('keydown', this.#onKeydown);
44
+ }
45
+
46
+ // Stable #field arrows — connect/reconnect re-adds are idempotent (the browser
47
+ // dedups identical listeners), satisfying the trait system's symmetric-lifecycle rule.
48
+ #onClick = (e) => {
49
+ const opener = e.target.closest?.('[opens]');
50
+ if (opener && this.contains(opener)) { this.toggle(opener.getAttribute('opens')); return; }
51
+ const closer = e.target.closest?.('[close]');
52
+ if (closer && this.contains(closer)) this.close();
53
+ };
54
+ #onEmbedOpen = (e) => this.toggle(e.detail?.panel);
55
+ #onKeydown = (e) => {
56
+ if (e.key === 'Escape' && this.panel) { e.stopPropagation(); this.close(); }
57
+ };
58
+
59
+ /** the open panel's name, or '' */
60
+ get panel() { return this.getAttribute('panel') || ''; }
61
+
62
+ toggle(name) { return this.panel === name ? this.close() : this.open(name); }
63
+
64
+ open(name) {
65
+ if (!name) return;
66
+ const el = this.querySelector(`:scope > [panel="${CSS.escape(name)}"]`);
67
+ if (!el) return; // consumer must provide a matching [panel] surface
68
+ for (const p of this.querySelectorAll(':scope > [panel][active]')) {
69
+ if (p !== el) p.removeAttribute('active');
70
+ }
71
+ el.setAttribute('active', ''); // CSS show/slide hook
72
+ this.setAttribute('panel', name); // reflected state
73
+ // focus the panel without scrolling the framed surface (preventScroll)
74
+ requestAnimationFrame(() =>
75
+ el.querySelector('[autofocus], [close], button-ui, [tabindex]')?.focus?.({ preventScroll: true }));
76
+ this.#emit(name);
77
+ }
78
+
79
+ close() {
80
+ if (!this.panel) return;
81
+ this.querySelector(':scope > [panel][active]')?.removeAttribute('active');
82
+ this.removeAttribute('panel');
83
+ this.#emit('');
84
+ }
85
+
86
+ #emit(panel) {
87
+ this.dispatchEvent(new CustomEvent('embed:change', { detail: { panel }, bubbles: true }));
88
+ }
89
+ }
90
+
91
+ customElements.define('embed-shell', EmbedShell);
92
+ export { EmbedShell };
@@ -0,0 +1,127 @@
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
+ });
@@ -0,0 +1,74 @@
1
+ $schema: ../../../../scripts/schemas/component.yaml.schema.json
2
+ name: EmbedShell
3
+ tag: embed-shell
4
+ status: draft
5
+ component: AppShell
6
+ category: layout
7
+ version: 1
8
+ description: |
9
+ Embedded multi-surface shell — composes a primary [app] surface with secondary
10
+ [panel="<name>"] surfaces the consumer provides, arranging them responsively.
11
+ Peer of <admin-shell>. Content-agnostic: it orchestrates whatever [app] +
12
+ [panel] children it is given (it owns layout, not content), so any embedded app
13
+ supplies its own surfaces.
14
+
15
+ >=760px the open panel is an EQUAL 50/50 column beside the app and the frame
16
+ grows to fit; <760px it is a cover sheet that slides up over the app (identity
17
+ transform at rest, so popovers inside still anchor). Clean attribute contract
18
+ (ADR-0024, no data-*): [app], [panel="<name>"], trigger [opens="<name>"],
19
+ [close]; reflects [panel] on the shell + [active] on the open panel.
20
+
21
+ props:
22
+ panel:
23
+ type: string
24
+ reflect: true
25
+ description: |
26
+ The open panel's name (empty / absent = none). Reflected state. Set it by
27
+ clicking an [opens="<name>"] trigger, dispatching an `embed:open` event, or
28
+ calling .open(name) / .close() / .toggle(name).
29
+
30
+ events:
31
+ "embed:open":
32
+ description: Request to open or toggle a panel (consumed by the shell).
33
+ detail:
34
+ panel: string
35
+ "embed:change":
36
+ description: Fired after the open panel changes.
37
+ detail:
38
+ panel: string
39
+
40
+ slots:
41
+ default:
42
+ description: >-
43
+ Document-order children: one [app] primary surface + any number of
44
+ [panel="<name>"] secondary surfaces. The shell always shows [app] and reveals
45
+ the open [panel] (50/50 column >=760px, cover sheet <760px). Triggers
46
+ ([opens], [close]) may live anywhere inside.
47
+
48
+ states:
49
+ - name: idle
50
+ description: No panel open — the app fills the frame.
51
+ - name: open
52
+ description: A panel is open ([panel="<name>"] reflected; the panel carries [active]).
53
+
54
+ traits:
55
+ - resizable
56
+
57
+ a2ui:
58
+ rules:
59
+ - >-
60
+ embed-shell takes exactly one [app] child + any number of [panel="<name>"]
61
+ children. It owns layout, not content — the consumer supplies the surfaces.
62
+ - >-
63
+ Open a panel with an [opens="<name>"] trigger anywhere inside, or an
64
+ `embed:open` event; dismiss with [close]. Do not toggle visibility manually —
65
+ the shell reflects [panel] + sets [active] on the open panel.
66
+ - >-
67
+ Use clean attributes (ADR-0024) — [app], [panel], [opens], [close] — not the
68
+ legacy data-* shapes.
69
+
70
+ keywords: [embed, shell, layout, panel, multi-pane, cover-sheet, app-shell]
71
+ synonyms:
72
+ embed: [embedded, shell, app-shell, multi-pane]
73
+ panel: [pane, sheet, drawer]
74
+ related: [AdminShell, Frame, Drawer]
package/shell/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { AdminShell } from './admin-shell/admin-shell.js';
2
2
  export { AdminSidebar } from './admin-sidebar/admin-sidebar.js';
3
3
  export { AdminCommand } from './admin-command/admin-command.js';
4
+ export { EmbedShell } from './embed-shell/embed-shell.js';