@sveltia/ui 0.35.0 → 0.35.2
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/dist/components/calendar/calendar.svelte +17 -25
- package/dist/components/select/combobox.svelte +10 -7
- package/dist/components/text-editor/constants.test.d.ts +1 -0
- package/dist/components/text-editor/constants.test.js +98 -0
- package/dist/components/text-editor/store.svelte.test.d.ts +1 -0
- package/dist/components/text-editor/store.svelte.test.js +196 -0
- package/dist/components/text-editor/transformers/hr.test.d.ts +1 -0
- package/dist/components/text-editor/transformers/hr.test.js +108 -0
- package/dist/components/text-editor/transformers/table.test.d.ts +1 -0
- package/dist/components/text-editor/transformers/table.test.js +28 -0
- package/dist/components/text-field/text-input.svelte +12 -6
- package/dist/components/toast/toast.svelte +7 -3
- package/dist/services/events.svelte.js +66 -8
- package/dist/services/events.test.d.ts +1 -0
- package/dist/services/events.test.js +221 -0
- package/dist/services/group.svelte.d.ts +1 -0
- package/dist/services/group.svelte.js +15 -10
- package/dist/services/group.test.d.ts +1 -0
- package/dist/services/group.test.js +763 -0
- package/dist/services/i18n.d.ts +6 -0
- package/dist/services/i18n.js +4 -2
- package/dist/services/i18n.test.d.ts +1 -0
- package/dist/services/i18n.test.js +106 -0
- package/dist/services/popup.svelte.d.ts +1 -0
- package/dist/services/popup.svelte.js +11 -2
- package/dist/services/popup.test.d.ts +1 -0
- package/dist/services/popup.test.js +536 -0
- package/dist/services/select.test.d.ts +1 -0
- package/dist/services/select.test.js +69 -0
- package/package.json +10 -9
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/* eslint-disable jsdoc/require-description */
|
|
2
|
+
/* eslint-disable jsdoc/require-jsdoc */
|
|
3
|
+
/* eslint-disable jsdoc/require-param */
|
|
4
|
+
/* eslint-disable jsdoc/require-param-description */
|
|
5
|
+
/* eslint-disable jsdoc/require-returns */
|
|
6
|
+
/* eslint-disable lines-between-class-members */
|
|
7
|
+
/* eslint-disable max-classes-per-file */
|
|
8
|
+
|
|
9
|
+
import { get } from 'svelte/store';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { activatePopup } from './popup.svelte.js';
|
|
12
|
+
|
|
13
|
+
describe('Popup', () => {
|
|
14
|
+
/** @type {HTMLButtonElement} */
|
|
15
|
+
let anchor;
|
|
16
|
+
/** @type {HTMLDialogElement} */
|
|
17
|
+
let popup;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
anchor = /** @type {HTMLButtonElement} */ (document.createElement('button'));
|
|
21
|
+
popup = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
22
|
+
document.body.appendChild(anchor);
|
|
23
|
+
document.body.appendChild(popup);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
anchor.remove();
|
|
28
|
+
popup.remove();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should assign an id to the popup element', () => {
|
|
32
|
+
activatePopup(anchor, popup, 'bottom-left');
|
|
33
|
+
expect(popup.id).toBeTruthy();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should set aria-controls on the anchor to match the popup id', () => {
|
|
37
|
+
activatePopup(anchor, popup, 'bottom-left');
|
|
38
|
+
expect(anchor.getAttribute('aria-controls')).toBe(popup.id);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should expose the open store defaulting to false', () => {
|
|
42
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
43
|
+
|
|
44
|
+
expect(get(instance.open)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should set aria-expanded to false initially', () => {
|
|
48
|
+
activatePopup(anchor, popup, 'bottom-left');
|
|
49
|
+
expect(anchor.getAttribute('aria-expanded')).toBe('false');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should toggle open store to true on anchor click', () => {
|
|
53
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
54
|
+
|
|
55
|
+
anchor.click();
|
|
56
|
+
expect(get(instance.open)).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should set aria-expanded to true after anchor click', () => {
|
|
60
|
+
activatePopup(anchor, popup, 'bottom-left');
|
|
61
|
+
anchor.click();
|
|
62
|
+
expect(anchor.getAttribute('aria-expanded')).toBe('true');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should not toggle open when anchor is disabled', () => {
|
|
66
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
67
|
+
|
|
68
|
+
anchor.setAttribute('aria-disabled', 'true');
|
|
69
|
+
anchor.click();
|
|
70
|
+
expect(get(instance.open)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should not toggle open when anchor is read-only', () => {
|
|
74
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
75
|
+
|
|
76
|
+
anchor.setAttribute('aria-readonly', 'true');
|
|
77
|
+
anchor.click();
|
|
78
|
+
expect(get(instance.open)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should report isDisabled as false when not disabled', () => {
|
|
82
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
83
|
+
|
|
84
|
+
expect(instance.isDisabled).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should report isDisabled as true when aria-disabled is set', () => {
|
|
88
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
89
|
+
|
|
90
|
+
anchor.setAttribute('aria-disabled', 'true');
|
|
91
|
+
expect(instance.isDisabled).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should report isReadOnly as false when not read-only', () => {
|
|
95
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
96
|
+
|
|
97
|
+
expect(instance.isReadOnly).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should report isReadOnly as true when aria-readonly is set', () => {
|
|
101
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
102
|
+
|
|
103
|
+
anchor.setAttribute('aria-readonly', 'true');
|
|
104
|
+
expect(instance.isReadOnly).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should close on Escape keydown on popup', () => {
|
|
108
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
109
|
+
|
|
110
|
+
anchor.click(); // open first
|
|
111
|
+
popup.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: false }));
|
|
112
|
+
expect(get(instance.open)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should close when a menu option inside popup is clicked', () => {
|
|
116
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
117
|
+
const menuItem = document.createElement('div');
|
|
118
|
+
|
|
119
|
+
menuItem.setAttribute('role', 'menuitem');
|
|
120
|
+
popup.appendChild(menuItem);
|
|
121
|
+
anchor.click(); // open
|
|
122
|
+
menuItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
123
|
+
expect(get(instance.open)).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should toggle open to true on Enter keydown on anchor', () => {
|
|
127
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
128
|
+
|
|
129
|
+
anchor.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
130
|
+
expect(get(instance.open)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should toggle open to true on Space keydown on anchor', () => {
|
|
134
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
135
|
+
|
|
136
|
+
anchor.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }));
|
|
137
|
+
expect(get(instance.open)).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should not toggle open when anchor keydown has a modifier key', () => {
|
|
141
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
142
|
+
|
|
143
|
+
anchor.dispatchEvent(
|
|
144
|
+
new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true }),
|
|
145
|
+
);
|
|
146
|
+
expect(get(instance.open)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should close on click directly on the popup backdrop element', () => {
|
|
150
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
151
|
+
|
|
152
|
+
anchor.click(); // open
|
|
153
|
+
popup.dispatchEvent(new MouseEvent('click', { bubbles: false }));
|
|
154
|
+
expect(get(instance.open)).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should remove aria-controls from anchor when popup closes after being open', () => {
|
|
158
|
+
activatePopup(anchor, popup, 'bottom-left');
|
|
159
|
+
anchor.click(); // open → aria-expanded='true'
|
|
160
|
+
anchor.click(); // close → aria-controls should be removed
|
|
161
|
+
expect(anchor.getAttribute('aria-controls')).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should set aria-expanded to false after closing', () => {
|
|
165
|
+
activatePopup(anchor, popup, 'bottom-left');
|
|
166
|
+
anchor.click(); // open
|
|
167
|
+
anchor.click(); // close
|
|
168
|
+
expect(anchor.getAttribute('aria-expanded')).toBe('false');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('Popup - hideImmediately', () => {
|
|
173
|
+
/** @type {HTMLButtonElement} */
|
|
174
|
+
let anchor;
|
|
175
|
+
/** @type {HTMLDialogElement} */
|
|
176
|
+
let popup;
|
|
177
|
+
|
|
178
|
+
beforeEach(() => {
|
|
179
|
+
vi.useFakeTimers();
|
|
180
|
+
anchor = /** @type {HTMLButtonElement} */ (document.createElement('button'));
|
|
181
|
+
popup = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
182
|
+
document.body.appendChild(anchor);
|
|
183
|
+
document.body.appendChild(popup);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
afterEach(() => {
|
|
187
|
+
anchor.remove();
|
|
188
|
+
popup.remove();
|
|
189
|
+
vi.useRealTimers();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should set open to false immediately when hideImmediately is called', async () => {
|
|
193
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
194
|
+
|
|
195
|
+
anchor.click();
|
|
196
|
+
expect(get(instance.open)).toBe(true);
|
|
197
|
+
|
|
198
|
+
const hidePromise = instance.hideImmediately();
|
|
199
|
+
|
|
200
|
+
expect(get(instance.open)).toBe(false);
|
|
201
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
202
|
+
await hidePromise;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should temporarily set popup.hidden and then restore it', async () => {
|
|
206
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
207
|
+
|
|
208
|
+
anchor.click();
|
|
209
|
+
|
|
210
|
+
const hidePromise = instance.hideImmediately();
|
|
211
|
+
|
|
212
|
+
expect(popup.hidden).toBe(true);
|
|
213
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
214
|
+
await hidePromise;
|
|
215
|
+
expect(popup.hidden).toBe(false);
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('Popup - transitionstart', () => {
|
|
220
|
+
/** @type {HTMLButtonElement} */
|
|
221
|
+
let anchor;
|
|
222
|
+
/** @type {HTMLDialogElement} */
|
|
223
|
+
let popup;
|
|
224
|
+
/** @type {HTMLDivElement} */
|
|
225
|
+
let wrapper;
|
|
226
|
+
|
|
227
|
+
beforeEach(() => {
|
|
228
|
+
vi.useFakeTimers();
|
|
229
|
+
anchor = /** @type {HTMLButtonElement} */ (document.createElement('button'));
|
|
230
|
+
popup = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
231
|
+
wrapper = document.createElement('div');
|
|
232
|
+
wrapper.appendChild(anchor);
|
|
233
|
+
document.body.appendChild(wrapper);
|
|
234
|
+
document.body.appendChild(popup);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
afterEach(() => {
|
|
238
|
+
wrapper.remove();
|
|
239
|
+
popup.remove();
|
|
240
|
+
vi.useRealTimers();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('should hide popup when anchor transitions inside a .hiding ancestor', async () => {
|
|
244
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
245
|
+
|
|
246
|
+
anchor.click();
|
|
247
|
+
expect(get(instance.open)).toBe(true);
|
|
248
|
+
wrapper.classList.add('hiding');
|
|
249
|
+
anchor.dispatchEvent(new Event('transitionstart'));
|
|
250
|
+
expect(get(instance.open)).toBe(false);
|
|
251
|
+
wrapper.classList.remove('hiding');
|
|
252
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('should not hide popup on transitionstart when no hiding ancestor', async () => {
|
|
256
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
257
|
+
|
|
258
|
+
anchor.click();
|
|
259
|
+
anchor.dispatchEvent(new Event('transitionstart'));
|
|
260
|
+
expect(get(instance.open)).toBe(true);
|
|
261
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('Popup - IntersectionObserver on anchor (lines 179-180)', () => {
|
|
266
|
+
/** @type {HTMLButtonElement} */
|
|
267
|
+
let anchor;
|
|
268
|
+
/** @type {HTMLDialogElement} */
|
|
269
|
+
let popup;
|
|
270
|
+
/** @type {typeof globalThis.IntersectionObserver} */
|
|
271
|
+
let OrigIObserver;
|
|
272
|
+
/** @type {((entries: any[]) => void)[]} */
|
|
273
|
+
let ioCallbacks;
|
|
274
|
+
|
|
275
|
+
beforeEach(() => {
|
|
276
|
+
vi.useFakeTimers();
|
|
277
|
+
anchor = /** @type {HTMLButtonElement} */ (document.createElement('button'));
|
|
278
|
+
popup = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
279
|
+
document.body.appendChild(anchor);
|
|
280
|
+
document.body.appendChild(popup);
|
|
281
|
+
ioCallbacks = [];
|
|
282
|
+
OrigIObserver = globalThis.IntersectionObserver;
|
|
283
|
+
|
|
284
|
+
// Stub IntersectionObserver to capture callbacks
|
|
285
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
286
|
+
globalThis.IntersectionObserver = /** @type {any} */ (
|
|
287
|
+
class {
|
|
288
|
+
/** @param {(entries: any[]) => void} cb */
|
|
289
|
+
constructor(cb) {
|
|
290
|
+
ioCallbacks.push(cb);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
observe() {}
|
|
294
|
+
unobserve() {}
|
|
295
|
+
disconnect() {}
|
|
296
|
+
}
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
afterEach(async () => {
|
|
301
|
+
anchor.remove();
|
|
302
|
+
popup.remove();
|
|
303
|
+
globalThis.IntersectionObserver = OrigIObserver;
|
|
304
|
+
await vi.runAllTimersAsync();
|
|
305
|
+
vi.useRealTimers();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should hide when anchor becomes non-intersecting while popup is open', async () => {
|
|
309
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
310
|
+
|
|
311
|
+
anchor.click();
|
|
312
|
+
expect(get(instance.open)).toBe(true);
|
|
313
|
+
|
|
314
|
+
// ioCallbacks[0] = position observer; ioCallbacks[1] = anchor visibility observer
|
|
315
|
+
const anchorVisibilityCallback = ioCallbacks[1];
|
|
316
|
+
|
|
317
|
+
anchorVisibilityCallback([{ isIntersecting: false }]);
|
|
318
|
+
expect(get(instance.open)).toBe(false);
|
|
319
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should not hide when anchor leaves viewport but popup is already closed', () => {
|
|
323
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
324
|
+
// popup is not opened
|
|
325
|
+
const anchorVisibilityCallback = ioCallbacks[1];
|
|
326
|
+
|
|
327
|
+
anchorVisibilityCallback([{ isIntersecting: false }]);
|
|
328
|
+
expect(get(instance.open)).toBe(false);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
describe('Popup - IntersectionObserver position callback (lines 35-132)', () => {
|
|
332
|
+
/** @type {HTMLButtonElement} */
|
|
333
|
+
let anchor;
|
|
334
|
+
/** @type {HTMLDialogElement} */
|
|
335
|
+
let popup;
|
|
336
|
+
/** @type {HTMLDivElement} */
|
|
337
|
+
let content;
|
|
338
|
+
/** @type {typeof globalThis.IntersectionObserver} */
|
|
339
|
+
let OrigIObserver;
|
|
340
|
+
/** @type {((entries: any[]) => void)[]} */
|
|
341
|
+
let ioCallbacks;
|
|
342
|
+
|
|
343
|
+
beforeEach(() => {
|
|
344
|
+
anchor = /** @type {HTMLButtonElement} */ (document.createElement('button'));
|
|
345
|
+
popup = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
346
|
+
// The position callback accesses popup.querySelector('.content')
|
|
347
|
+
content = document.createElement('div');
|
|
348
|
+
content.className = 'content';
|
|
349
|
+
popup.appendChild(content);
|
|
350
|
+
document.body.appendChild(anchor);
|
|
351
|
+
document.body.appendChild(popup);
|
|
352
|
+
|
|
353
|
+
ioCallbacks = [];
|
|
354
|
+
OrigIObserver = globalThis.IntersectionObserver;
|
|
355
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
356
|
+
globalThis.IntersectionObserver = /** @type {any} */ (
|
|
357
|
+
class {
|
|
358
|
+
/** @param {(entries: any[]) => void} cb */
|
|
359
|
+
constructor(cb) {
|
|
360
|
+
ioCallbacks.push(cb);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
observe() {}
|
|
364
|
+
unobserve() {}
|
|
365
|
+
disconnect() {}
|
|
366
|
+
}
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
afterEach(() => {
|
|
371
|
+
anchor.remove();
|
|
372
|
+
popup.remove();
|
|
373
|
+
globalThis.IntersectionObserver = OrigIObserver;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Helper to create a fake intersection entry.
|
|
378
|
+
*/
|
|
379
|
+
const makeEntry = ({
|
|
380
|
+
top = 100,
|
|
381
|
+
bottom = 150,
|
|
382
|
+
left = 50,
|
|
383
|
+
right = 300,
|
|
384
|
+
vw = 800,
|
|
385
|
+
vh = 600,
|
|
386
|
+
} = {}) => ({
|
|
387
|
+
intersectionRect: { top, bottom, left, right, width: right - left, height: bottom - top },
|
|
388
|
+
rootBounds: { width: vw, height: vh },
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should set popup style on intersection (bottom-left, normal case)', () => {
|
|
392
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
393
|
+
|
|
394
|
+
// ioCallbacks[0] is the position observer
|
|
395
|
+
ioCallbacks[0]([makeEntry()]);
|
|
396
|
+
|
|
397
|
+
// Style should be updated with computed inset
|
|
398
|
+
const style = get(instance.style);
|
|
399
|
+
|
|
400
|
+
expect(style.inset).toBeTruthy();
|
|
401
|
+
expect(style.zIndex).toBe(1000);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should skip entry when intersectionRect is null', () => {
|
|
405
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
406
|
+
|
|
407
|
+
ioCallbacks[0]([{ intersectionRect: null, rootBounds: null }]);
|
|
408
|
+
|
|
409
|
+
// Style remains at default (no crash)
|
|
410
|
+
const style = get(instance.style);
|
|
411
|
+
|
|
412
|
+
expect(style.inset).toBeUndefined();
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('should switch position to top-left when content overflows bottom', () => {
|
|
416
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
417
|
+
|
|
418
|
+
// contentHeight > bottomMargin AND topMargin > bottomMargin → switches to top-
|
|
419
|
+
Object.defineProperty(content, 'scrollHeight', { configurable: true, get: () => 500 });
|
|
420
|
+
ioCallbacks[0]([makeEntry({ top: 400, bottom: 450, left: 50, right: 300, vw: 800, vh: 500 })]);
|
|
421
|
+
|
|
422
|
+
const style = get(instance.style);
|
|
423
|
+
|
|
424
|
+
// Position changed to top-left → bottom should be calculated (not auto)
|
|
425
|
+
expect(style.inset).not.toBeUndefined();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should switch position to bottom-right when content overflows to the right', () => {
|
|
429
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
430
|
+
|
|
431
|
+
// contentWidth > remaining right space → switch to bottom-right
|
|
432
|
+
Object.defineProperty(content, 'scrollWidth', { configurable: true, get: () => 760 });
|
|
433
|
+
ioCallbacks[0]([makeEntry({ left: 50, right: 300 })]);
|
|
434
|
+
|
|
435
|
+
const style = get(instance.style);
|
|
436
|
+
|
|
437
|
+
expect(style.inset).not.toBeUndefined();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('should switch position to bottom-left when content overflows to the left', () => {
|
|
441
|
+
const instance = activatePopup(anchor, popup, 'bottom-right');
|
|
442
|
+
|
|
443
|
+
// contentWidth causes left edge to be < 8 → switch to bottom-left
|
|
444
|
+
Object.defineProperty(content, 'scrollWidth', { configurable: true, get: () => 290 });
|
|
445
|
+
ioCallbacks[0]([makeEntry({ left: 50, right: 100 })]);
|
|
446
|
+
|
|
447
|
+
const style = get(instance.style);
|
|
448
|
+
|
|
449
|
+
expect(style.inset).not.toBeUndefined();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('should normalize RTL position when document.dir is rtl', () => {
|
|
453
|
+
document.documentElement.setAttribute('dir', 'rtl');
|
|
454
|
+
|
|
455
|
+
const instance = activatePopup(anchor, popup, 'bottom-left');
|
|
456
|
+
|
|
457
|
+
ioCallbacks[0]([makeEntry()]);
|
|
458
|
+
|
|
459
|
+
const style = get(instance.style);
|
|
460
|
+
|
|
461
|
+
expect(style.inset).not.toBeUndefined();
|
|
462
|
+
document.documentElement.removeAttribute('dir');
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
describe('Popup - ResizeObserver callback (lines 223-224)', () => {
|
|
466
|
+
/** @type {HTMLButtonElement} */
|
|
467
|
+
let anchor;
|
|
468
|
+
/** @type {HTMLDialogElement} */
|
|
469
|
+
let popup;
|
|
470
|
+
/** @type {typeof globalThis.ResizeObserver} */
|
|
471
|
+
let OrigRObs;
|
|
472
|
+
/** @type {((entries: any[]) => void) | undefined} */
|
|
473
|
+
let resizeCallback;
|
|
474
|
+
/** @type {typeof globalThis.IntersectionObserver | undefined} */
|
|
475
|
+
let _OrigIO;
|
|
476
|
+
|
|
477
|
+
beforeEach(() => {
|
|
478
|
+
vi.useFakeTimers();
|
|
479
|
+
anchor = /** @type {HTMLButtonElement} */ (document.createElement('button'));
|
|
480
|
+
popup = /** @type {HTMLDialogElement} */ (document.createElement('dialog'));
|
|
481
|
+
document.body.appendChild(anchor);
|
|
482
|
+
document.body.appendChild(popup);
|
|
483
|
+
OrigRObs = globalThis.ResizeObserver;
|
|
484
|
+
|
|
485
|
+
// Stub ResizeObserver to capture its callback
|
|
486
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
487
|
+
globalThis.ResizeObserver = /** @type {any} */ (
|
|
488
|
+
class {
|
|
489
|
+
/** @param {any} cb */
|
|
490
|
+
constructor(cb) {
|
|
491
|
+
resizeCallback = cb;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
observe() {}
|
|
495
|
+
unobserve() {}
|
|
496
|
+
disconnect() {}
|
|
497
|
+
}
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
// Also stub IntersectionObserver to avoid errors
|
|
501
|
+
const OrigIO = globalThis.IntersectionObserver;
|
|
502
|
+
|
|
503
|
+
// eslint-disable-next-line jsdoc/require-jsdoc
|
|
504
|
+
globalThis.IntersectionObserver = /** @type {any} */ (
|
|
505
|
+
class {
|
|
506
|
+
// eslint-disable-next-line no-useless-constructor, no-empty-function
|
|
507
|
+
constructor() {}
|
|
508
|
+
observe() {}
|
|
509
|
+
unobserve() {}
|
|
510
|
+
disconnect() {}
|
|
511
|
+
}
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
_OrigIO = OrigIO;
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
afterEach(async () => {
|
|
518
|
+
anchor.remove();
|
|
519
|
+
popup.remove();
|
|
520
|
+
globalThis.ResizeObserver = OrigRObs;
|
|
521
|
+
globalThis.IntersectionObserver = /** @type {any} */ (_OrigIO);
|
|
522
|
+
await vi.runAllTimersAsync();
|
|
523
|
+
vi.useRealTimers();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should schedule checkPosition via RAF when resize is observed', async () => {
|
|
527
|
+
activatePopup(anchor, popup, 'bottom-left');
|
|
528
|
+
|
|
529
|
+
// Trigger the ResizeObserver callback (lines 223-224: cancelAnimationFrame +
|
|
530
|
+
// requestAnimationFrame)
|
|
531
|
+
/** @type {any} */ (resizeCallback)?.([]);
|
|
532
|
+
await vi.advanceTimersByTimeAsync(16); // flush rAF
|
|
533
|
+
// No crash; just verifies these lines execute
|
|
534
|
+
expect(true).toBe(true);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getSelectedItemDetail } from './select.svelte.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Helper to create a minimal element-like object with the given dataset values.
|
|
6
|
+
* @param {Record<string, string>} dataset Dataset key-value pairs.
|
|
7
|
+
* @returns {HTMLElement} A fake element with the given dataset.
|
|
8
|
+
*/
|
|
9
|
+
const makeElement = (dataset) => /** @type {HTMLElement} */ (/** @type {unknown} */ ({ dataset }));
|
|
10
|
+
|
|
11
|
+
describe('getSelectedItemDetail', () => {
|
|
12
|
+
it('should return a string value by default', () => {
|
|
13
|
+
const el = makeElement({ value: 'hello', name: 'field', label: 'Hello' });
|
|
14
|
+
const detail = getSelectedItemDetail(el);
|
|
15
|
+
|
|
16
|
+
expect(detail.type).toBe('string');
|
|
17
|
+
expect(detail.value).toBe('hello');
|
|
18
|
+
expect(detail.name).toBe('field');
|
|
19
|
+
expect(detail.label).toBe('Hello');
|
|
20
|
+
expect(detail.target).toBe(el);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('should coerce value to empty string when value is absent and type is string', () => {
|
|
24
|
+
const el = makeElement({ name: 'field' });
|
|
25
|
+
const detail = getSelectedItemDetail(el);
|
|
26
|
+
|
|
27
|
+
expect(detail.type).toBe('string');
|
|
28
|
+
expect(detail.value).toBe('');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should return a number value when type is number', () => {
|
|
32
|
+
const el = makeElement({ type: 'number', value: '42' });
|
|
33
|
+
const detail = getSelectedItemDetail(el);
|
|
34
|
+
|
|
35
|
+
expect(detail.type).toBe('number');
|
|
36
|
+
expect(detail.value).toBe(42);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should return null for a non-numeric value when type is number', () => {
|
|
40
|
+
const el = makeElement({ type: 'number', value: 'abc' });
|
|
41
|
+
const detail = getSelectedItemDetail(el);
|
|
42
|
+
|
|
43
|
+
expect(detail.value).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should return true for "true" when type is boolean', () => {
|
|
47
|
+
const el = makeElement({ type: 'boolean', value: 'true' });
|
|
48
|
+
const detail = getSelectedItemDetail(el);
|
|
49
|
+
|
|
50
|
+
expect(detail.type).toBe('boolean');
|
|
51
|
+
expect(detail.value).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return false for any non-"true" string when type is boolean', () => {
|
|
55
|
+
const el = makeElement({ type: 'boolean', value: 'false' });
|
|
56
|
+
const detail = getSelectedItemDetail(el);
|
|
57
|
+
|
|
58
|
+
expect(detail.value).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should leave value unchanged for an unknown/custom type (else-if string branch false)', () => {
|
|
62
|
+
const el = makeElement({ type: 'date', value: '2024-01-01' });
|
|
63
|
+
const detail = getSelectedItemDetail(el);
|
|
64
|
+
|
|
65
|
+
expect(detail.type).toBe('date');
|
|
66
|
+
// value is the raw dataset string when type is not string/number/boolean
|
|
67
|
+
expect(detail.value).toBe('2024-01-01');
|
|
68
|
+
});
|
|
69
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sveltia/ui",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -29,18 +29,19 @@
|
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@sveltejs/adapter-auto": "^7.0.1",
|
|
32
|
-
"@sveltejs/kit": "^2.
|
|
32
|
+
"@sveltejs/kit": "^2.55.0",
|
|
33
33
|
"@sveltejs/package": "^2.5.7",
|
|
34
|
-
"@sveltejs/vite-plugin-svelte": "^
|
|
35
|
-
"@vitest/coverage-v8": "^4.0
|
|
34
|
+
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
35
|
+
"@vitest/coverage-v8": "^4.1.0",
|
|
36
36
|
"cspell": "^9.7.0",
|
|
37
37
|
"eslint": "^8.57.1",
|
|
38
38
|
"eslint-config-airbnb-base": "^15.0.0",
|
|
39
39
|
"eslint-config-prettier": "^10.1.8",
|
|
40
40
|
"eslint-plugin-import": "^2.32.0",
|
|
41
|
-
"eslint-plugin-jsdoc": "^62.
|
|
41
|
+
"eslint-plugin-jsdoc": "^62.8.0",
|
|
42
42
|
"eslint-plugin-svelte": "^2.46.1",
|
|
43
|
-
"
|
|
43
|
+
"happy-dom": "^20.8.4",
|
|
44
|
+
"oxlint": "^1.56.0",
|
|
44
45
|
"postcss": "^8.5.8",
|
|
45
46
|
"postcss-html": "^1.8.1",
|
|
46
47
|
"prettier": "^3.8.1",
|
|
@@ -49,12 +50,12 @@
|
|
|
49
50
|
"stylelint": "^17.4.0",
|
|
50
51
|
"stylelint-config-recommended-scss": "^17.0.0",
|
|
51
52
|
"stylelint-scss": "^7.0.0",
|
|
52
|
-
"svelte": "^5.
|
|
53
|
+
"svelte": "^5.54.0",
|
|
53
54
|
"svelte-check": "^4.4.5",
|
|
54
55
|
"svelte-preprocess": "^6.0.3",
|
|
55
56
|
"tslib": "^2.8.1",
|
|
56
|
-
"vite": "^
|
|
57
|
-
"vitest": "^4.0
|
|
57
|
+
"vite": "^8.0.0",
|
|
58
|
+
"vitest": "^4.1.0"
|
|
58
59
|
},
|
|
59
60
|
"exports": {
|
|
60
61
|
".": {
|