@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
|
@@ -1,383 +0,0 @@
|
|
|
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('does not latch [collapsed] on small-transient RO sample while host is display:none (§FB-49)', async () => {
|
|
120
|
-
// Capture the RO callback so we can fire it manually with a transient width.
|
|
121
|
-
let roCallback;
|
|
122
|
-
const savedRO = globalThis.ResizeObserver;
|
|
123
|
-
globalThis.ResizeObserver = class {
|
|
124
|
-
constructor(cb) { roCallback = cb; }
|
|
125
|
-
observe() {} unobserve() {} disconnect() {}
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
129
|
-
const pane = sb.querySelector('pane-ui');
|
|
130
|
-
pane.style.width = '240px';
|
|
131
|
-
|
|
132
|
-
// Simulate display:none via inline style; stub getComputedStyle to reflect it.
|
|
133
|
-
sb.style.display = 'none';
|
|
134
|
-
const origGCS = globalThis.getComputedStyle;
|
|
135
|
-
globalThis.getComputedStyle = (el, ps) => {
|
|
136
|
-
if (el === sb && el.style.display === 'none') return { display: 'none' };
|
|
137
|
-
return origGCS(el, ps);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
// Fire the RO with a small-nonzero transient (48px = SNAP_THRESHOLD).
|
|
141
|
-
if (roCallback) roCallback([{ target: pane, contentRect: { width: 48 } }]);
|
|
142
|
-
await tick();
|
|
143
|
-
|
|
144
|
-
expect(sb.collapsed).toBe(false);
|
|
145
|
-
expect(sb.hasAttribute('collapsed')).toBe(false);
|
|
146
|
-
|
|
147
|
-
globalThis.ResizeObserver = savedRO;
|
|
148
|
-
globalThis.getComputedStyle = origGCS;
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('defers [collapsed]=true by one rAF for small-transient samples (§FB-50)', async () => {
|
|
152
|
-
let roCallback;
|
|
153
|
-
const savedRO = globalThis.ResizeObserver;
|
|
154
|
-
globalThis.ResizeObserver = class {
|
|
155
|
-
constructor(cb) { roCallback = cb; }
|
|
156
|
-
observe() {} unobserve() {} disconnect() {}
|
|
157
|
-
};
|
|
158
|
-
|
|
159
|
-
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
160
|
-
const pane = sb.querySelector('pane-ui');
|
|
161
|
-
// Simulate a small width on the pane (CSS grid collapse transient or drag).
|
|
162
|
-
pane.style.width = '48px';
|
|
163
|
-
|
|
164
|
-
if (roCallback) roCallback([{ target: pane }]);
|
|
165
|
-
// Immediately after RO callback — collapsed must NOT be set yet (rAF pending).
|
|
166
|
-
expect(sb.collapsed).toBe(false);
|
|
167
|
-
|
|
168
|
-
// After the rAF fires — pane still 48px, host visible → collapse is applied.
|
|
169
|
-
await new Promise((r) => requestAnimationFrame(r));
|
|
170
|
-
await tick();
|
|
171
|
-
expect(sb.collapsed).toBe(true);
|
|
172
|
-
|
|
173
|
-
globalThis.ResizeObserver = savedRO;
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('rAF check skips collapse if display:none applied before frame fires (§FB-50 wide→narrow race)', async () => {
|
|
177
|
-
let roCallback;
|
|
178
|
-
const savedRO = globalThis.ResizeObserver;
|
|
179
|
-
globalThis.ResizeObserver = class {
|
|
180
|
-
constructor(cb) { roCallback = cb; }
|
|
181
|
-
observe() {} unobserve() {} disconnect() {}
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
185
|
-
const pane = sb.querySelector('pane-ui');
|
|
186
|
-
// Transient 48px — CSS grid collapsed the track before display:none applied.
|
|
187
|
-
pane.style.width = '48px';
|
|
188
|
-
|
|
189
|
-
if (roCallback) roCallback([{ target: pane }]);
|
|
190
|
-
expect(sb.collapsed).toBe(false); // deferred, not set yet
|
|
191
|
-
|
|
192
|
-
// Simulate display:none propagating before the rAF executes (the race window).
|
|
193
|
-
sb.style.display = 'none';
|
|
194
|
-
const origGCS = globalThis.getComputedStyle;
|
|
195
|
-
globalThis.getComputedStyle = (el, ps) => {
|
|
196
|
-
if (el === sb && el.style.display === 'none') return { display: 'none' };
|
|
197
|
-
return origGCS(el, ps);
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
// rAF fires — guard sees display:none → collapsed stays false.
|
|
201
|
-
await new Promise((r) => requestAnimationFrame(r));
|
|
202
|
-
await tick();
|
|
203
|
-
expect(sb.collapsed).toBe(false);
|
|
204
|
-
expect(sb.hasAttribute('collapsed')).toBe(false);
|
|
205
|
-
|
|
206
|
-
globalThis.ResizeObserver = savedRO;
|
|
207
|
-
globalThis.getComputedStyle = origGCS;
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
// ── GH #223: expand() permanently wedges when the width transition is enabled ──
|
|
211
|
-
// Root cause: pane-ui's real CSS transition (editor-shell.bespoke.css) animates
|
|
212
|
-
// width from 48px toward the restore target; early ResizeObserver samples
|
|
213
|
-
// mid-transition are legitimately still <= SNAP_THRESHOLD, and the old
|
|
214
|
-
// #syncCollapsed() couldn't tell that apart from a genuine drag-to-collapse
|
|
215
|
-
// settle — flipping [collapsed] back to true mid-flight re-matches the
|
|
216
|
-
// `!important` collapsed-width CSS rule, which snaps the pane back to 48px
|
|
217
|
-
// and permanently interrupts the transition (no further resize ever occurs
|
|
218
|
-
// to self-correct). These tests mock a nonzero `transitionDuration` on the
|
|
219
|
-
// pane (jsdom/happy-dom has no real CSS loaded) to exercise the suppression.
|
|
220
|
-
describe('expand() transition race (GH #223)', () => {
|
|
221
|
-
function withPaneTransitionDuration(pane, durationStr) {
|
|
222
|
-
const origGCS = globalThis.getComputedStyle;
|
|
223
|
-
globalThis.getComputedStyle = (el, ps) => {
|
|
224
|
-
if (el === pane) return { ...origGCS(el, ps), transitionDuration: durationStr };
|
|
225
|
-
return origGCS(el, ps);
|
|
226
|
-
};
|
|
227
|
-
return () => { globalThis.getComputedStyle = origGCS; };
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
it('does not latch [collapsed] on a small mid-transition RO sample while an explicit expand() is in flight', async () => {
|
|
231
|
-
let roCallback;
|
|
232
|
-
const savedRO = globalThis.ResizeObserver;
|
|
233
|
-
globalThis.ResizeObserver = class {
|
|
234
|
-
constructor(cb) { roCallback = cb; }
|
|
235
|
-
observe() {} unobserve() {} disconnect() {}
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
239
|
-
const pane = sb.querySelector('pane-ui');
|
|
240
|
-
const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
|
|
241
|
-
pane.style.width = '48px';
|
|
242
|
-
sb.collapsed = true;
|
|
243
|
-
|
|
244
|
-
sb.expand(); // sets pane width to the restore target + marks expand-in-flight
|
|
245
|
-
|
|
246
|
-
// Mid-transition: the observer samples an intermediate, still-small width.
|
|
247
|
-
pane.style.width = '77px';
|
|
248
|
-
if (roCallback) roCallback([{ target: pane }]);
|
|
249
|
-
await new Promise((r) => requestAnimationFrame(r));
|
|
250
|
-
await tick();
|
|
251
|
-
|
|
252
|
-
// Must NOT have snapped back to collapsed — this is exactly the wedge bug.
|
|
253
|
-
expect(sb.collapsed).toBe(false);
|
|
254
|
-
expect(sb.hasAttribute('collapsed')).toBe(false);
|
|
255
|
-
|
|
256
|
-
restoreGCS();
|
|
257
|
-
globalThis.ResizeObserver = savedRO;
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
it('clears expand-in-flight and re-syncs on transitionend, landing on the correct final state', async () => {
|
|
261
|
-
let roCallback;
|
|
262
|
-
const savedRO = globalThis.ResizeObserver;
|
|
263
|
-
globalThis.ResizeObserver = class {
|
|
264
|
-
constructor(cb) { roCallback = cb; }
|
|
265
|
-
observe() {} unobserve() {} disconnect() {}
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
269
|
-
const pane = sb.querySelector('pane-ui');
|
|
270
|
-
const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
|
|
271
|
-
pane.style.width = '48px';
|
|
272
|
-
sb.collapsed = true;
|
|
273
|
-
|
|
274
|
-
sb.expand();
|
|
275
|
-
pane.style.width = '240px'; // transition has now settled at its target
|
|
276
|
-
// `propertyName` is a TransitionEvent field, not a plain Event
|
|
277
|
-
// constructor option (the constructor silently drops unknown keys) —
|
|
278
|
-
// assign it as an own property after construction instead.
|
|
279
|
-
const transitionEnd = new Event('transitionend', { bubbles: false });
|
|
280
|
-
transitionEnd.propertyName = 'width';
|
|
281
|
-
pane.dispatchEvent(transitionEnd);
|
|
282
|
-
await tick();
|
|
283
|
-
|
|
284
|
-
expect(sb.collapsed).toBe(false);
|
|
285
|
-
expect(sb.hasAttribute('collapsed')).toBe(false);
|
|
286
|
-
|
|
287
|
-
// With expand-in-flight cleared, a genuinely small sample (e.g. a
|
|
288
|
-
// later user drag-to-collapse) must resume being trusted normally.
|
|
289
|
-
pane.style.width = '48px';
|
|
290
|
-
if (roCallback) roCallback([{ target: pane }]);
|
|
291
|
-
await new Promise((r) => requestAnimationFrame(r));
|
|
292
|
-
await tick();
|
|
293
|
-
expect(sb.collapsed).toBe(true);
|
|
294
|
-
|
|
295
|
-
restoreGCS();
|
|
296
|
-
globalThis.ResizeObserver = savedRO;
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it('falls back to a bounded timer sized to the computed duration when transitionend never fires', async () => {
|
|
300
|
-
vi.useFakeTimers();
|
|
301
|
-
let roCallback;
|
|
302
|
-
const savedRO = globalThis.ResizeObserver;
|
|
303
|
-
globalThis.ResizeObserver = class {
|
|
304
|
-
constructor(cb) { roCallback = cb; }
|
|
305
|
-
observe() {} unobserve() {} disconnect() {}
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
309
|
-
const pane = sb.querySelector('pane-ui');
|
|
310
|
-
const restoreGCS = withPaneTransitionDuration(pane, '0.18s');
|
|
311
|
-
pane.style.width = '48px';
|
|
312
|
-
sb.collapsed = true;
|
|
313
|
-
|
|
314
|
-
sb.expand();
|
|
315
|
-
pane.style.width = '77px'; // stuck reporting a small value; no transitionend ever arrives
|
|
316
|
-
if (roCallback) roCallback([{ target: pane }]);
|
|
317
|
-
await vi.advanceTimersByTimeAsync(50); // rAF-equivalent settle window (fake timers)
|
|
318
|
-
|
|
319
|
-
// Still suppressed — the 180ms+50ms safety timer hasn't elapsed yet.
|
|
320
|
-
expect(sb.collapsed).toBe(false);
|
|
321
|
-
|
|
322
|
-
pane.style.width = '240px'; // by the time the safety timer fires, transition is done
|
|
323
|
-
await vi.advanceTimersByTimeAsync(300); // past the 180+50ms fallback
|
|
324
|
-
expect(sb.collapsed).toBe(false); // final re-sync sees the real (large) width
|
|
325
|
-
|
|
326
|
-
restoreGCS();
|
|
327
|
-
globalThis.ResizeObserver = savedRO;
|
|
328
|
-
vi.useRealTimers();
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
it('skips expand-in-flight suppression entirely when the computed transition duration is 0 (no transition to race)', async () => {
|
|
332
|
-
let roCallback;
|
|
333
|
-
const savedRO = globalThis.ResizeObserver;
|
|
334
|
-
globalThis.ResizeObserver = class {
|
|
335
|
-
constructor(cb) { roCallback = cb; }
|
|
336
|
-
observe() {} unobserve() {} disconnect() {}
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
const sb = mount('<editor-sidebar slot="leading" collapsible><pane-ui></pane-ui></editor-sidebar>');
|
|
340
|
-
const pane = sb.querySelector('pane-ui');
|
|
341
|
-
// No transitionDuration mock — jsdom/happy-dom defaults to '0s'.
|
|
342
|
-
pane.style.width = '48px';
|
|
343
|
-
sb.collapsed = true;
|
|
344
|
-
|
|
345
|
-
sb.expand();
|
|
346
|
-
// With a 0-duration transition, the width jump is instantaneous — the
|
|
347
|
-
// observer's first sample already lands at the final (large) width.
|
|
348
|
-
pane.style.width = '240px';
|
|
349
|
-
if (roCallback) roCallback([{ target: pane }]);
|
|
350
|
-
await tick();
|
|
351
|
-
|
|
352
|
-
expect(sb.collapsed).toBe(false);
|
|
353
|
-
globalThis.ResizeObserver = savedRO;
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
it('handles missing inner <pane-ui> gracefully (no crash)', () => {
|
|
358
|
-
const sb = mount('<editor-sidebar slot="leading"></editor-sidebar>');
|
|
359
|
-
// Should not throw
|
|
360
|
-
expect(() => sb.toggle()).not.toThrow();
|
|
361
|
-
expect(() => sb.collapse()).not.toThrow();
|
|
362
|
-
expect(() => sb.expand()).not.toThrow();
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
it('reflects [resizing] via property assignment', async () => {
|
|
366
|
-
const sb = mount('<editor-sidebar slot="leading"><pane-ui></pane-ui></editor-sidebar>');
|
|
367
|
-
sb.resizing = true;
|
|
368
|
-
await tick();
|
|
369
|
-
expect(sb.hasAttribute('resizing')).toBe(true);
|
|
370
|
-
sb.resizing = false;
|
|
371
|
-
await tick();
|
|
372
|
-
expect(sb.hasAttribute('resizing')).toBe(false);
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
it('clears [resizing] on document pointerup', async () => {
|
|
376
|
-
const sb = mount('<editor-sidebar slot="leading"><pane-ui></pane-ui></editor-sidebar>');
|
|
377
|
-
sb.resizing = true;
|
|
378
|
-
await tick();
|
|
379
|
-
document.dispatchEvent(new Event('pointerup'));
|
|
380
|
-
await tick();
|
|
381
|
-
expect(sb.resizing).toBe(false);
|
|
382
|
-
});
|
|
383
|
-
});
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
import '../../../web-components/core/element.js';
|
|
3
|
-
import './editor-toolbar.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('editor-toolbar', () => {
|
|
19
|
-
it('registers editor-toolbar as a custom element', () => {
|
|
20
|
-
expect(customElements.get('editor-toolbar')).toBeDefined();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('defaults to fullScreen=false', () => {
|
|
24
|
-
const t = mount('<editor-toolbar></editor-toolbar>');
|
|
25
|
-
expect(t.fullScreen).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('reflects [full-screen] via property assignment', async () => {
|
|
29
|
-
const t = mount('<editor-toolbar></editor-toolbar>');
|
|
30
|
-
t.fullScreen = true;
|
|
31
|
-
await tick();
|
|
32
|
-
expect(t.hasAttribute('full-screen')).toBe(true);
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('honors initial [full-screen] attribute on connect', () => {
|
|
36
|
-
const t = mount('<editor-toolbar full-screen></editor-toolbar>');
|
|
37
|
-
expect(t.fullScreen).toBe(true);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('bubbles toolbar-action event from [data-toolbar-action] children', async () => {
|
|
41
|
-
const wrap = document.createElement('div');
|
|
42
|
-
wrap.innerHTML = `<editor-toolbar>
|
|
43
|
-
<button data-toolbar-action="save">Save</button>
|
|
44
|
-
<button data-toolbar-action="undo">Undo</button>
|
|
45
|
-
</editor-toolbar>`;
|
|
46
|
-
document.body.appendChild(wrap);
|
|
47
|
-
const toolbar = wrap.firstElementChild;
|
|
48
|
-
const saveBtn = toolbar.querySelector('[data-toolbar-action="save"]');
|
|
49
|
-
|
|
50
|
-
const onAction = vi.fn();
|
|
51
|
-
toolbar.addEventListener('toolbar-action', onAction);
|
|
52
|
-
|
|
53
|
-
saveBtn.click();
|
|
54
|
-
await tick();
|
|
55
|
-
|
|
56
|
-
expect(onAction).toHaveBeenCalledTimes(1);
|
|
57
|
-
expect(onAction.mock.calls[0][0].detail).toEqual({ name: 'save' });
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('does not fire toolbar-action for clicks outside [data-toolbar-action]', async () => {
|
|
61
|
-
const wrap = document.createElement('div');
|
|
62
|
-
wrap.innerHTML = `<editor-toolbar>
|
|
63
|
-
<span>Title</span>
|
|
64
|
-
<button>Plain button</button>
|
|
65
|
-
</editor-toolbar>`;
|
|
66
|
-
document.body.appendChild(wrap);
|
|
67
|
-
const toolbar = wrap.firstElementChild;
|
|
68
|
-
const plainBtn = toolbar.querySelector('button');
|
|
69
|
-
|
|
70
|
-
const onAction = vi.fn();
|
|
71
|
-
toolbar.addEventListener('toolbar-action', onAction);
|
|
72
|
-
|
|
73
|
-
plainBtn.click();
|
|
74
|
-
await tick();
|
|
75
|
-
|
|
76
|
-
expect(onAction).not.toHaveBeenCalled();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('cleanup on disconnect — removes click listener', () => {
|
|
80
|
-
const t = mount('<editor-toolbar></editor-toolbar>');
|
|
81
|
-
const removeSpy = vi.spyOn(t, 'removeEventListener');
|
|
82
|
-
t.remove();
|
|
83
|
-
const removedTypes = removeSpy.mock.calls.map((args) => args[0]);
|
|
84
|
-
expect(removedTypes).toContain('click');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('event bubbles up beyond the toolbar', async () => {
|
|
88
|
-
const outer = document.createElement('div');
|
|
89
|
-
document.body.appendChild(outer);
|
|
90
|
-
outer.innerHTML = `<editor-toolbar>
|
|
91
|
-
<button data-toolbar-action="settings"></button>
|
|
92
|
-
</editor-toolbar>`;
|
|
93
|
-
const onOuterAction = vi.fn();
|
|
94
|
-
outer.addEventListener('toolbar-action', onOuterAction);
|
|
95
|
-
outer.querySelector('button').click();
|
|
96
|
-
await tick();
|
|
97
|
-
expect(onOuterAction).toHaveBeenCalled();
|
|
98
|
-
});
|
|
99
|
-
});
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import './form-popover.js';
|
|
3
|
-
|
|
4
|
-
const mount = (html) => {
|
|
5
|
-
const wrap = document.createElement('div');
|
|
6
|
-
wrap.innerHTML = html;
|
|
7
|
-
document.body.appendChild(wrap);
|
|
8
|
-
return wrap.firstElementChild;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
describe('form-popover-ui', () => {
|
|
12
|
-
beforeEach(() => { document.body.innerHTML = ''; });
|
|
13
|
-
|
|
14
|
-
it('adopts the fragment into the popover body, order preserved', () => {
|
|
15
|
-
const el = mount(`<form-popover-ui label="Options">
|
|
16
|
-
<check-ui label="a"></check-ui>
|
|
17
|
-
<divider-ui></divider-ui>
|
|
18
|
-
<input-ui placeholder="name"></input-ui>
|
|
19
|
-
</form-popover>`);
|
|
20
|
-
const body = el.querySelector('[data-form-popover-body]');
|
|
21
|
-
expect(body).toBeTruthy();
|
|
22
|
-
const tags = [...body.children].map((c) => c.tagName.toLowerCase());
|
|
23
|
-
expect(tags).toEqual(['check-ui', 'divider-ui', 'input-ui']);
|
|
24
|
-
// the popover skeleton is NOT part of the adopted fragment
|
|
25
|
-
expect(el.querySelector(':scope > popover-ui')).toBeTruthy();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('summary counts checked check-ui: "N selected" / "N total"', () => {
|
|
29
|
-
const el = mount(`<form-popover-ui label="Option items">
|
|
30
|
-
<check-ui label="a" checked></check-ui>
|
|
31
|
-
<check-ui label="b"></check-ui>
|
|
32
|
-
<check-ui label="c" checked></check-ui>
|
|
33
|
-
</form-popover>`);
|
|
34
|
-
const trigger = el.querySelector('[data-form-popover-trigger]');
|
|
35
|
-
expect(trigger.getAttribute('text')).toBe('Option items · 2 selected');
|
|
36
|
-
expect(el.summary).toEqual({ selected: 2, total: 3 });
|
|
37
|
-
// uncheck everything → falls back to total
|
|
38
|
-
for (const box of el.querySelectorAll('check-ui')) box.removeAttribute('checked');
|
|
39
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
40
|
-
expect(trigger.getAttribute('text')).toBe('Option items · 3 total');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('renders the bare label when the fragment has no check-ui', () => {
|
|
44
|
-
const el = mount(`<form-popover-ui label="Rename">
|
|
45
|
-
<input-ui placeholder="name"></input-ui>
|
|
46
|
-
</form-popover>`);
|
|
47
|
-
expect(el.querySelector('[data-form-popover-trigger]').getAttribute('text')).toBe('Rename');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('[heading] renders and clears', async () => {
|
|
51
|
-
const el = mount(`<form-popover-ui label="x" heading="Select options"><check-ui label="a"></check-ui></form-popover>`);
|
|
52
|
-
expect(el.querySelector('[data-form-popover-heading]').textContent).toBe('Select options');
|
|
53
|
-
el.heading = '';
|
|
54
|
-
await Promise.resolve();
|
|
55
|
-
expect(el.querySelector('[data-form-popover-heading]')).toBeNull();
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('emits summary-change with detail', () => {
|
|
59
|
-
const el = mount(`<form-popover-ui label="x"><check-ui label="a"></check-ui></form-popover>`);
|
|
60
|
-
let detail = null;
|
|
61
|
-
el.addEventListener('summary-change', (e) => { detail = e.detail; });
|
|
62
|
-
el.querySelector('check-ui').setAttribute('checked', '');
|
|
63
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
64
|
-
expect(detail).toEqual({ selected: 1, total: 1 });
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('falls back to "Options" with no label and no checkboxes (a11y guard)', () => {
|
|
68
|
-
const el = mount(`<form-popover-ui><input-ui placeholder="name"></input-ui></form-popover-ui>`);
|
|
69
|
-
expect(el.querySelector('[data-form-popover-trigger]').getAttribute('text')).toBe('Options');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('passes [placement] through to the internal popover, including updates', async () => {
|
|
73
|
-
const el = mount(`<form-popover-ui label="x" placement="top"><check-ui label="a"></check-ui></form-popover-ui>`);
|
|
74
|
-
expect(el.querySelector(':scope > popover-ui').getAttribute('placement')).toBe('top');
|
|
75
|
-
el.placement = 'bottom-end';
|
|
76
|
-
await Promise.resolve();
|
|
77
|
-
expect(el.querySelector(':scope > popover-ui').getAttribute('placement')).toBe('bottom-end');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('[label] updates re-render the summary reactively', async () => {
|
|
81
|
-
const el = mount(`<form-popover-ui label="Old"><check-ui label="a" checked></check-ui></form-popover-ui>`);
|
|
82
|
-
el.label = 'New';
|
|
83
|
-
await Promise.resolve();
|
|
84
|
-
expect(el.querySelector('[data-form-popover-trigger]').getAttribute('text')).toBe('New · 1 selected');
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('disconnect removes the summary listener (no stale updates after removal)', () => {
|
|
88
|
-
const el = mount(`<form-popover-ui label="x"><check-ui label="a"></check-ui></form-popover-ui>`);
|
|
89
|
-
const trigger = el.querySelector('[data-form-popover-trigger]');
|
|
90
|
-
el.remove();
|
|
91
|
-
el.querySelector('check-ui').setAttribute('checked', '');
|
|
92
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
93
|
-
expect(trigger.getAttribute('text')).toBe('x · 1 total');
|
|
94
|
-
});
|
|
95
|
-
});
|