@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.
- package/CHANGELOG.md +14 -4
- package/dist/everything.min.js +91 -91
- package/dist/web-modules.min.css +1 -1
- package/package.json +1 -1
- package/shell/embed-shell/embed-shell.a2ui.json +98 -0
- package/shell/embed-shell/embed-shell.css +82 -0
- package/shell/embed-shell/embed-shell.d.ts +32 -0
- package/shell/embed-shell/embed-shell.js +92 -0
- package/shell/embed-shell/embed-shell.test.js +127 -0
- package/shell/embed-shell/embed-shell.yaml +74 -0
- package/shell/index.js +1 -0
|
@@ -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