@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.
- package/CHANGELOG.md +17 -0
- package/dist/everything.min.js +15 -15
- package/form/form-popover/form-popover.a2ui.json +1 -1
- package/form/form-popover/form-popover.js +22 -9
- package/form/form-popover/form-popover.yaml +13 -11
- package/package.json +16 -1
- package/chat/chat-composer/chat-composer.test.js +0 -112
- package/chat/chat-sidebar/chat-sidebar.test.js +0 -110
- package/chat/chat-thread/chat-thread.test.js +0 -82
- package/editor/editor-canvas/editor-canvas.test.js +0 -100
- package/editor/editor-sidebar/editor-sidebar.test.js +0 -383
- package/editor/editor-toolbar/editor-toolbar.test.js +0 -99
- package/form/form-popover/form-popover.test.js +0 -95
- package/shell/admin-command/admin-command.test.js +0 -115
- package/shell/admin-shell/admin-shell.test.js +0 -185
- package/shell/admin-sidebar/admin-sidebar.test.js +0 -173
- package/shell/embed-shell/embed-shell.test.js +0 -127
- package/simple/simple-shell/simple-shell.test.js +0 -83
- package/theme/theme-panel/theme-panel.test.js +0 -431
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://adiaui.dev/a2ui/v0_9/components/FormPopover.json",
|
|
4
4
|
"title": "FormPopover",
|
|
5
|
-
"description": "Module-tier \"form fragment in a popover\" (operator mock, 2026-07-27): a\nselect-style summary trigger that opens an anchored panel holding ANY\nform primitives — check-ui groups, radio-ui groups, input-ui,\ndivider-ui, field-ui — slotted as ordinary light-DOM children. The\nmodule owns the trigger + popover packaging and a live selection\nsummary; the slotted controls keep their own name/value/event\ncontracts untouched.\n\nSummary contract
|
|
5
|
+
"description": "Module-tier \"form fragment in a popover\" (operator mock, 2026-07-27): a\nselect-style summary trigger that opens an anchored panel holding ANY\nform primitives — check-ui groups, radio-ui groups, input-ui,\ndivider-ui, field-ui — slotted as ordinary light-DOM children. The\nmodule owns the trigger + popover packaging and a live selection\nsummary; the slotted controls keep their own name/value/event\ncontracts untouched.\n\nSummary contract (gh#474 — identical to select-ui[summary-label], so the\ntwo multi-select triggers speak one language): none checked reads just\n`{label}` (which doubles as the placeholder, since [label] is required\nhere), exactly one reads THAT check-ui's own label, and more than one\nreads `{label} (N)`. Radio groups, inputs, and other controls\ndeliberately do not count — checkboxes are the only selection semantic\nthat reads unambiguously in a summary.\n\nDecision rule vs adjacent surfaces (inherits popover-ui's): a pure\naction list is menu-ui; a single-value choice is select-ui (which owns\nits option list — do NOT rebuild select inside this module); an\nedge-anchored multi-field form is drawer-ui. form-popover-ui is for the\nin-between: a small anchored fragment mixing selection controls and\ninputs, e.g. a filter panel or a quick-save form.\n",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"allOf": [
|
|
8
8
|
{
|
|
@@ -66,12 +66,16 @@ class FormPopover extends UIElement {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/**
|
|
69
|
+
/**
|
|
70
|
+
* { selected, total, checked } over the slotted check-ui population.
|
|
71
|
+
* `checked` is the checked elements themselves — the summary needs the
|
|
72
|
+
* single-selection element to read its own label (gh#474).
|
|
73
|
+
*/
|
|
70
74
|
get summary() {
|
|
71
75
|
const boxes = this.#bodyEl ? this.#bodyEl.querySelectorAll('check-ui') : [];
|
|
72
|
-
|
|
73
|
-
for (const box of boxes) if (box.hasAttribute('checked') || box.checked)
|
|
74
|
-
return { selected, total: boxes.length };
|
|
76
|
+
const checked = [];
|
|
77
|
+
for (const box of boxes) if (box.hasAttribute('checked') || box.checked) checked.push(box);
|
|
78
|
+
return { selected: checked.length, total: boxes.length, checked };
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
#stamp() {
|
|
@@ -113,14 +117,23 @@ class FormPopover extends UIElement {
|
|
|
113
117
|
el.textContent = this.heading;
|
|
114
118
|
}
|
|
115
119
|
|
|
120
|
+
/** The own label of a slotted check-ui — [label] prop, else its text. */
|
|
121
|
+
#labelOf(box) {
|
|
122
|
+
return (box.getAttribute('label') || box.label || box.textContent || '').trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
116
125
|
#updateSummary() {
|
|
117
126
|
if (!this.#triggerEl) return;
|
|
118
|
-
const { selected, total } = this.summary;
|
|
127
|
+
const { selected, total, checked } = this.summary;
|
|
128
|
+
// gh#474 — ONE summary contract across the two multi-select triggers:
|
|
129
|
+
// none → the label alone (it doubles as select-ui's placeholder, since
|
|
130
|
+
// [label] is required here), one → that control's own label, more →
|
|
131
|
+
// "Label (N)". Was "Label · N selected", which contradicted
|
|
132
|
+
// select-ui[summary-label]'s ruled format (gh#442) and blocked
|
|
133
|
+
// consumers from swapping a hand-rolled composition for this module.
|
|
119
134
|
let text = this.label;
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
text = text ? `${text} · ${count}` : count;
|
|
123
|
-
}
|
|
135
|
+
if (selected === 1 && checked[0]) text = this.#labelOf(checked[0]) || text;
|
|
136
|
+
else if (selected > 1) text = text ? `${text} (${selected})` : `${selected}`;
|
|
124
137
|
// [label] is required by contract; this fallback only guards the
|
|
125
138
|
// no-label + no-checkbox misuse so the trigger never renders as an
|
|
126
139
|
// empty, nameless button (a11y — CodeRabbit finding on #441).
|
|
@@ -15,12 +15,13 @@ description: |
|
|
|
15
15
|
summary; the slotted controls keep their own name/value/event
|
|
16
16
|
contracts untouched.
|
|
17
17
|
|
|
18
|
-
Summary contract
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
check-ui
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
Summary contract (gh#474 — identical to select-ui[summary-label], so the
|
|
19
|
+
two multi-select triggers speak one language): none checked reads just
|
|
20
|
+
`{label}` (which doubles as the placeholder, since [label] is required
|
|
21
|
+
here), exactly one reads THAT check-ui's own label, and more than one
|
|
22
|
+
reads `{label} (N)`. Radio groups, inputs, and other controls
|
|
23
|
+
deliberately do not count — checkboxes are the only selection semantic
|
|
24
|
+
that reads unambiguously in a summary.
|
|
24
25
|
|
|
25
26
|
Decision rule vs adjacent surfaces (inherits popover-ui's): a pure
|
|
26
27
|
action list is menu-ui; a single-value choice is select-ui (which owns
|
|
@@ -97,12 +98,13 @@ a2ui:
|
|
|
97
98
|
small MIXED fragment (checks + radios + an input) behind a summary
|
|
98
99
|
trigger.
|
|
99
100
|
- >-
|
|
100
|
-
The trigger summary counts CheckBox children only
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
101
|
+
The trigger summary counts CheckBox children only — one checked
|
|
102
|
+
shows its own label, more show "Label (N)", none shows the bare
|
|
103
|
+
label (same contract as Select[summary-label]); Radio and Input
|
|
104
|
+
children never count. Give every Radio child one shared name per
|
|
105
|
+
group or the browser treats them as independent.
|
|
104
106
|
props:
|
|
105
|
-
label: { type: string, description: "Summary-trigger prefix
|
|
107
|
+
label: { type: string, description: "Summary-trigger text — shown alone when nothing is checked (its placeholder role) and as the \"Label (N)\" prefix when more than one is" }
|
|
106
108
|
heading: { type: string, description: "Optional panel heading" }
|
|
107
109
|
placement: { type: string, description: "Popover placement (default bottom-start)" }
|
|
108
110
|
children: form primitives (CheckBox, Radio, Input, Divider, Field)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adia-ai/web-modules",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.21",
|
|
4
4
|
"description": "AdiaUI composite custom elements \u2014 shell, chat, editor, runtime clusters built from @adia-ai/web-components primitives. Subpath exports per cluster.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -203,6 +203,20 @@
|
|
|
203
203
|
"import": "./generative/index.js",
|
|
204
204
|
"default": "./generative/index.js"
|
|
205
205
|
},
|
|
206
|
+
"./form": {
|
|
207
|
+
"types": "./form/index.d.ts",
|
|
208
|
+
"import": "./form/index.js",
|
|
209
|
+
"default": "./form/index.js"
|
|
210
|
+
},
|
|
211
|
+
"./form/*": {
|
|
212
|
+
"types": "./form/*/*.d.ts",
|
|
213
|
+
"import": "./form/*/*.js",
|
|
214
|
+
"default": "./form/*/*.js"
|
|
215
|
+
},
|
|
216
|
+
"./form/*.css": {
|
|
217
|
+
"types": "./css-module.d.ts",
|
|
218
|
+
"default": "./form/*/*.css"
|
|
219
|
+
},
|
|
206
220
|
"./package.json": "./package.json"
|
|
207
221
|
},
|
|
208
222
|
"files": [
|
|
@@ -239,6 +253,7 @@
|
|
|
239
253
|
"!form/**/*.examples.html",
|
|
240
254
|
"!form/**/*.examples.js",
|
|
241
255
|
"!form/**/*.html",
|
|
256
|
+
"!**/*.test.js",
|
|
242
257
|
"index.js",
|
|
243
258
|
"css-module.d.ts",
|
|
244
259
|
"web-modules.sheet.d.ts",
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
import '../../../web-components/core/element.js';
|
|
3
|
-
import './chat-composer.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('chat-composer', () => {
|
|
19
|
-
it('registers chat-composer as a custom element', () => {
|
|
20
|
-
expect(customElements.get('chat-composer')).toBeDefined();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('defaults to disabled=false', () => {
|
|
24
|
-
const c = mount('<chat-composer></chat-composer>');
|
|
25
|
-
expect(c.disabled).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('reflects [disabled] via property assignment', async () => {
|
|
29
|
-
const c = mount('<chat-composer></chat-composer>');
|
|
30
|
-
c.disabled = true;
|
|
31
|
-
await tick();
|
|
32
|
-
expect(c.hasAttribute('disabled')).toBe(true);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('exposes .focus() / .clear() / .input getter', () => {
|
|
36
|
-
const c = mount('<chat-composer></chat-composer>');
|
|
37
|
-
expect(typeof c.focus).toBe('function');
|
|
38
|
-
expect(typeof c.clear).toBe('function');
|
|
39
|
-
expect('input' in c).toBe(true);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('returns null .input when no inner input element', () => {
|
|
43
|
-
const c = mount('<chat-composer></chat-composer>');
|
|
44
|
-
expect(c.input).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('forwards inner submit as composer-submit event', async () => {
|
|
48
|
-
// Plain input-like element with submit dispatch
|
|
49
|
-
const wrap = document.createElement('div');
|
|
50
|
-
wrap.innerHTML = `<chat-composer>
|
|
51
|
-
<chat-input-ui></chat-input-ui>
|
|
52
|
-
</chat-composer>`;
|
|
53
|
-
document.body.appendChild(wrap);
|
|
54
|
-
const composer = wrap.firstElementChild;
|
|
55
|
-
const innerInput = composer.querySelector('chat-input-ui');
|
|
56
|
-
|
|
57
|
-
const onComposerSubmit = vi.fn();
|
|
58
|
-
composer.addEventListener('composer-submit', onComposerSubmit);
|
|
59
|
-
|
|
60
|
-
innerInput.dispatchEvent(new CustomEvent('submit', { detail: { text: 'hello' } }));
|
|
61
|
-
await tick();
|
|
62
|
-
|
|
63
|
-
expect(onComposerSubmit).toHaveBeenCalledTimes(1);
|
|
64
|
-
expect(onComposerSubmit.mock.calls[0][0].detail).toEqual({ text: 'hello' });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('propagates [disabled] to inner input via attribute', async () => {
|
|
68
|
-
const wrap = document.createElement('div');
|
|
69
|
-
wrap.innerHTML = `<chat-composer>
|
|
70
|
-
<chat-input-ui></chat-input-ui>
|
|
71
|
-
</chat-composer>`;
|
|
72
|
-
document.body.appendChild(wrap);
|
|
73
|
-
const composer = wrap.firstElementChild;
|
|
74
|
-
const innerInput = composer.querySelector('chat-input-ui');
|
|
75
|
-
|
|
76
|
-
composer.disabled = true;
|
|
77
|
-
await tick();
|
|
78
|
-
|
|
79
|
-
expect(innerInput.hasAttribute('disabled')).toBe(true);
|
|
80
|
-
|
|
81
|
-
composer.disabled = false;
|
|
82
|
-
await tick();
|
|
83
|
-
|
|
84
|
-
expect(innerInput.hasAttribute('disabled')).toBe(false);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('cleanup on disconnect — removes inner submit listener', () => {
|
|
88
|
-
const wrap = document.createElement('div');
|
|
89
|
-
wrap.innerHTML = `<chat-composer>
|
|
90
|
-
<chat-input-ui></chat-input-ui>
|
|
91
|
-
</chat-composer>`;
|
|
92
|
-
document.body.appendChild(wrap);
|
|
93
|
-
const composer = wrap.firstElementChild;
|
|
94
|
-
const innerInput = composer.querySelector('chat-input-ui');
|
|
95
|
-
const removeSpy = vi.spyOn(innerInput, 'removeEventListener');
|
|
96
|
-
|
|
97
|
-
composer.remove();
|
|
98
|
-
|
|
99
|
-
const removedTypes = removeSpy.mock.calls.map((args) => args[0]);
|
|
100
|
-
expect(removedTypes).toContain('submit');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('handles input-ui as inner input fallback', () => {
|
|
104
|
-
const wrap = document.createElement('div');
|
|
105
|
-
wrap.innerHTML = `<chat-composer>
|
|
106
|
-
<input-ui></input-ui>
|
|
107
|
-
</chat-composer>`;
|
|
108
|
-
document.body.appendChild(wrap);
|
|
109
|
-
const composer = wrap.firstElementChild;
|
|
110
|
-
expect(composer.input?.tagName?.toLowerCase()).toBe('input-ui');
|
|
111
|
-
});
|
|
112
|
-
});
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import '../../../web-components/core/element.js';
|
|
3
|
-
import './chat-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
|
-
originalRect = HTMLElement.prototype.getBoundingClientRect;
|
|
22
|
-
HTMLElement.prototype.getBoundingClientRect = function () {
|
|
23
|
-
const inline = this.style?.width || '';
|
|
24
|
-
const w = parseFloat(inline) || 240;
|
|
25
|
-
return { width: w, height: 600, top: 0, left: 0, right: w, bottom: 600, x: 0, y: 0 };
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
afterEach(() => {
|
|
30
|
-
if (originalRect) HTMLElement.prototype.getBoundingClientRect = originalRect;
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('chat-sidebar', () => {
|
|
34
|
-
it('registers chat-sidebar as a custom element', () => {
|
|
35
|
-
expect(customElements.get('chat-sidebar')).toBeDefined();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('defaults to collapsed=false on connect', () => {
|
|
39
|
-
const sb = mount('<chat-sidebar slot="leading"></chat-sidebar>');
|
|
40
|
-
expect(sb.collapsed).toBe(false);
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('reflects [collapsed] via property assignment', async () => {
|
|
44
|
-
const sb = mount('<chat-sidebar slot="leading"></chat-sidebar>');
|
|
45
|
-
sb.collapsed = true;
|
|
46
|
-
await tick();
|
|
47
|
-
expect(sb.hasAttribute('collapsed')).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('reflects [resizing] via property assignment', async () => {
|
|
51
|
-
const sb = mount('<chat-sidebar slot="leading"></chat-sidebar>');
|
|
52
|
-
sb.resizing = true;
|
|
53
|
-
await tick();
|
|
54
|
-
expect(sb.hasAttribute('resizing')).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('exposes .toggle() / .collapse() / .expand() public methods', () => {
|
|
58
|
-
const sb = mount('<chat-sidebar slot="leading" collapsible></chat-sidebar>');
|
|
59
|
-
expect(typeof sb.toggle).toBe('function');
|
|
60
|
-
expect(typeof sb.collapse).toBe('function');
|
|
61
|
-
expect(typeof sb.expand).toBe('function');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('persists width to chat-namespaced localStorage key (not collide with admin)', async () => {
|
|
65
|
-
const sb = mount('<chat-sidebar slot="leading" collapsible></chat-sidebar>');
|
|
66
|
-
sb.style.width = '240px';
|
|
67
|
-
sb.collapse();
|
|
68
|
-
expect(sb.collapsed).toBe(true);
|
|
69
|
-
// localStorage key should be chat-namespaced
|
|
70
|
-
expect(localStorage.getItem('adia-chat-sidebar-leading')).not.toBeNull();
|
|
71
|
-
// Should NOT have written to the admin namespace
|
|
72
|
-
expect(localStorage.getItem('adia-sidebar-leading')).toBeNull();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('uses [name] override for the localStorage key', () => {
|
|
76
|
-
const sb = mount('<chat-sidebar slot="leading" name="conversations" collapsible></chat-sidebar>');
|
|
77
|
-
sb.style.width = '200px';
|
|
78
|
-
sb.collapse();
|
|
79
|
-
expect(localStorage.getItem('adia-chat-sidebar-conversations')).not.toBeNull();
|
|
80
|
-
expect(localStorage.getItem('adia-chat-sidebar-leading')).toBeNull();
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('toggle() returns the new collapsed value', () => {
|
|
84
|
-
const sb = mount('<chat-sidebar slot="leading" collapsible></chat-sidebar>');
|
|
85
|
-
sb.style.width = '200px';
|
|
86
|
-
const result = sb.toggle();
|
|
87
|
-
expect(result).toBe(sb.collapsed);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('dispatches sidebar-toggle event on toggle()', () => {
|
|
91
|
-
const sb = mount('<chat-sidebar slot="leading" collapsible></chat-sidebar>');
|
|
92
|
-
sb.style.width = '200px';
|
|
93
|
-
const onToggle = vi.fn();
|
|
94
|
-
sb.addEventListener('sidebar-toggle', onToggle);
|
|
95
|
-
sb.toggle();
|
|
96
|
-
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
97
|
-
expect(onToggle.mock.calls[0][0].detail).toEqual(
|
|
98
|
-
expect.objectContaining({ name: expect.any(String), expanded: expect.any(Boolean) })
|
|
99
|
-
);
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
it('cleans up resize handlers on disconnect', () => {
|
|
103
|
-
const sb = mount('<chat-sidebar slot="leading" resizable><div data-resize></div></chat-sidebar>');
|
|
104
|
-
const handle = sb.querySelector('[data-resize]');
|
|
105
|
-
const removeSpy = vi.spyOn(handle, 'removeEventListener');
|
|
106
|
-
sb.remove();
|
|
107
|
-
const removedTypes = removeSpy.mock.calls.map((args) => args[0]);
|
|
108
|
-
expect(removedTypes).toContain('pointerdown');
|
|
109
|
-
});
|
|
110
|
-
});
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import '../../../web-components/core/element.js';
|
|
3
|
-
import './chat-thread.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
|
-
globalThis.MutationObserver = class {
|
|
18
|
-
observe() {} disconnect() {}
|
|
19
|
-
};
|
|
20
|
-
originalRect = HTMLElement.prototype.getBoundingClientRect;
|
|
21
|
-
HTMLElement.prototype.getBoundingClientRect = function () {
|
|
22
|
-
return { width: 480, height: 600, top: 0, left: 0, right: 480, bottom: 600, x: 0, y: 0 };
|
|
23
|
-
};
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
if (originalRect) HTMLElement.prototype.getBoundingClientRect = originalRect;
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('chat-thread', () => {
|
|
31
|
-
it('registers chat-thread as a custom element', () => {
|
|
32
|
-
expect(customElements.get('chat-thread')).toBeDefined();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('defaults to streaming=false on connect', () => {
|
|
36
|
-
const t = mount('<chat-thread></chat-thread>');
|
|
37
|
-
expect(t.streaming).toBe(false);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('defaults to empty=true when no children', async () => {
|
|
41
|
-
const t = mount('<chat-thread></chat-thread>');
|
|
42
|
-
await tick();
|
|
43
|
-
expect(t.empty).toBe(true);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('reflects [streaming] via property assignment', async () => {
|
|
47
|
-
const t = mount('<chat-thread></chat-thread>');
|
|
48
|
-
t.streaming = true;
|
|
49
|
-
await tick();
|
|
50
|
-
expect(t.hasAttribute('streaming')).toBe(true);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('reflects [empty] via property assignment', async () => {
|
|
54
|
-
const t = mount('<chat-thread></chat-thread>');
|
|
55
|
-
t.empty = false;
|
|
56
|
-
await tick();
|
|
57
|
-
expect(t.hasAttribute('empty')).toBe(false);
|
|
58
|
-
t.empty = true;
|
|
59
|
-
await tick();
|
|
60
|
-
expect(t.hasAttribute('empty')).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('exposes .scrollToBottom() and .scrollToBottomInstant() methods', () => {
|
|
64
|
-
const t = mount('<chat-thread></chat-thread>');
|
|
65
|
-
expect(typeof t.scrollToBottom).toBe('function');
|
|
66
|
-
expect(typeof t.scrollToBottomInstant).toBe('function');
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it('does not count <chat-empty> stub as a message child for [empty] calc', () => {
|
|
70
|
-
const t = mount('<chat-thread><chat-empty></chat-empty></chat-thread>');
|
|
71
|
-
// chat-empty alone — empty should still be true
|
|
72
|
-
expect(t.empty).toBe(true);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('cleanup on disconnect — removes scroll listener', () => {
|
|
76
|
-
const t = mount('<chat-thread></chat-thread>');
|
|
77
|
-
const removeSpy = vi.spyOn(t, 'removeEventListener');
|
|
78
|
-
t.remove();
|
|
79
|
-
const removedTypes = removeSpy.mock.calls.map((args) => args[0]);
|
|
80
|
-
expect(removedTypes).toContain('scroll');
|
|
81
|
-
});
|
|
82
|
-
});
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
import '../../../web-components/core/element.js';
|
|
3
|
-
import './editor-canvas.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
|
-
globalThis.MutationObserver = class {
|
|
17
|
-
observe() {} disconnect() {}
|
|
18
|
-
};
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe('editor-canvas', () => {
|
|
22
|
-
it('registers editor-canvas as a custom element', () => {
|
|
23
|
-
expect(customElements.get('editor-canvas')).toBeDefined();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('defaults to focused=false', () => {
|
|
27
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
28
|
-
expect(c.focused).toBe(false);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('defaults to empty=true with no children', async () => {
|
|
32
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
33
|
-
await tick();
|
|
34
|
-
expect(c.empty).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('reflects [empty] via property assignment', async () => {
|
|
38
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
39
|
-
c.empty = false;
|
|
40
|
-
await tick();
|
|
41
|
-
expect(c.hasAttribute('empty')).toBe(false);
|
|
42
|
-
c.empty = true;
|
|
43
|
-
await tick();
|
|
44
|
-
expect(c.hasAttribute('empty')).toBe(true);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('reflects [focused] via property assignment', async () => {
|
|
48
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
49
|
-
c.focused = true;
|
|
50
|
-
await tick();
|
|
51
|
-
expect(c.hasAttribute('focused')).toBe(true);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('does not count <editor-canvas-empty> stub as a content child', () => {
|
|
55
|
-
const c = mount('<editor-canvas><editor-canvas-empty></editor-canvas-empty></editor-canvas>');
|
|
56
|
-
expect(c.empty).toBe(true);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('exposes .zoom getter/setter (default 1.0)', () => {
|
|
60
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
61
|
-
expect(c.zoom).toBe(1.0);
|
|
62
|
-
c.zoom = 2.0;
|
|
63
|
-
expect(c.zoom).toBe(2.0);
|
|
64
|
-
expect(c.style.getPropertyValue('--editor-canvas-zoom')).toBe('2');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('rejects invalid zoom values', () => {
|
|
68
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
69
|
-
c.zoom = 1.5;
|
|
70
|
-
c.zoom = 'invalid'; // should not change
|
|
71
|
-
expect(c.zoom).toBe(1.5);
|
|
72
|
-
c.zoom = -1; // should not change
|
|
73
|
-
expect(c.zoom).toBe(1.5);
|
|
74
|
-
c.zoom = 0; // should not change
|
|
75
|
-
expect(c.zoom).toBe(1.5);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('.resetView() restores zoom to 1.0', () => {
|
|
79
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
80
|
-
c.zoom = 2.5;
|
|
81
|
-
c.resetView();
|
|
82
|
-
expect(c.zoom).toBe(1.0);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('.focus() sets [focused] reflected', async () => {
|
|
86
|
-
const c = mount('<editor-canvas></editor-canvas>');
|
|
87
|
-
c.focus();
|
|
88
|
-
await tick();
|
|
89
|
-
expect(c.focused).toBe(true);
|
|
90
|
-
expect(c.hasAttribute('focused')).toBe(true);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('.blur() clears [focused] reflected', async () => {
|
|
94
|
-
const c = mount('<editor-canvas focused></editor-canvas>');
|
|
95
|
-
c.blur();
|
|
96
|
-
await tick();
|
|
97
|
-
expect(c.focused).toBe(false);
|
|
98
|
-
expect(c.hasAttribute('focused')).toBe(false);
|
|
99
|
-
});
|
|
100
|
-
});
|