clickgo 6.9.0 → 6.10.0

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.
Files changed (44) hide show
  1. package/AGENTS.md +16 -0
  2. package/README.md +4 -0
  3. package/dist/app/demo.cga +0 -0
  4. package/dist/app/task.cga +0 -0
  5. package/dist/clickgo.d.ts +2 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lib/command/native.d.ts +18 -0
  8. package/dist/lib/command/palette.d.ts +10 -0
  9. package/dist/lib/command/schema.d.ts +31 -0
  10. package/dist/lib/command.d.ts +218 -0
  11. package/dist/lib/native.d.ts +12 -0
  12. package/dist/lib/task.d.ts +2 -0
  13. package/doc/clickgo-rag.md +2376 -537
  14. package/package.json +9 -3
  15. package/test/core-module-loading.mjs +0 -169
  16. package/test/desktop.mjs +0 -715
  17. package/test/dock-lifecycle.mjs +0 -280
  18. package/test/dom-style.mjs +0 -261
  19. package/test/form-launcher.mjs +0 -177
  20. package/test/form-modal-layer.mjs +0 -75
  21. package/test/form-native-regions.mjs +0 -115
  22. package/test/form-native-style.mjs +0 -80
  23. package/test/form-native.mjs +0 -68
  24. package/test/form-notify-animation.mjs +0 -102
  25. package/test/form-notify.mjs +0 -90
  26. package/test/form-viewport.mjs +0 -127
  27. package/test/iconview.mjs +0 -70
  28. package/test/jodit-lifecycle.mjs +0 -212
  29. package/test/locale-integrity.mjs +0 -189
  30. package/test/pop-max-size.mjs +0 -66
  31. package/test/rtl-archive.mjs +0 -440
  32. package/test/rtl-direction.mjs +0 -193
  33. package/test/scroll.mjs +0 -145
  34. package/test/select.mjs +0 -885
  35. package/test/sidebar-toggle.mjs +0 -75
  36. package/test/task-package-loading.mjs +0 -94
  37. package/test/task-start.mjs +0 -141
  38. package/test/task-tray.mjs +0 -331
  39. package/test/tool-assets.mjs +0 -64
  40. package/test/tool-clone.mjs +0 -42
  41. package/test/toolbox.mjs +0 -88
  42. package/test/tuieditor-locale.mjs +0 -175
  43. package/test/vflow.mjs +0 -122
  44. package/test/web-drag-mask.mjs +0 -96
package/test/select.mjs DELETED
@@ -1,885 +0,0 @@
1
- // Run: node test/select.mjs
2
- import assert from 'node:assert/strict';
3
- import { readFile } from 'node:fs/promises';
4
- import vm from 'node:vm';
5
- import { JSDOM } from 'jsdom';
6
- import ts from 'typescript';
7
- import JSZip from 'jszip';
8
- import { clone, compar, getClassPrototype } from '../dist/lib/tool.js';
9
-
10
- const dom = new JSDOM('<!doctype html><html><body><system></system></body></html>');
11
- for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement', 'Node']) {
12
- Object.defineProperty(globalThis, name, { configurable: true, value: dom.window[name] });
13
- }
14
- const { createApp, h, reactive, nextTick } = await import('vue');
15
- const errors = [];
16
- const instances = [];
17
- const disposers = new Set();
18
- const sourceDirectory = process.env.CLICKGO_SELECT_TEST_SOURCE_DIR;
19
- const useArchive = process.env.CLICKGO_SELECT_TEST_ARCHIVE === '1';
20
- const archives = new Map();
21
- process.on('unhandledRejection', error => { errors.push(error); });
22
-
23
- class AbstractControl {
24
- get element() { return this.$el; }
25
- get refs() { return this.$refs; }
26
- get formFocus() { return true; }
27
- watch(source, callback, options) {
28
- return this.$watch(typeof source === 'string' ? () => this.props[source] : source, callback, options);
29
- }
30
- emit(...args) { this.$emit(...args); }
31
- nextTick() { return this.$nextTick(); }
32
- propBoolean(name) { return [true, 'true', ''].includes(this.props[name]); }
33
- propInt(name) { return parseInt(this.props[name]); }
34
- propNumber(name) { return Number(this.props[name]); }
35
- propArray(name) { return Array.isArray(this.props[name]) ? this.props[name] : [this.props[name]]; }
36
- parentByName() { return null; }
37
- fl(value) { return value; }
38
- l(key) { return this.localeData?.en[key] ?? key; }
39
- }
40
- const clickgo = {
41
- control: { AbstractControl },
42
- tool: { clone, compar, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)) },
43
- task: { sleep() {} },
44
- modules: { pointer: { click: (event, callback) => callback(), isTouch: () => false } },
45
- dom: { findParentByData: () => null },
46
- form: {
47
- showPop: element => { element.dataset.cgPopOpen = ''; },
48
- hidePop: element => { delete element.dataset.cgPopOpen; },
49
- },
50
- };
51
- clickgo.modules.clickgo = clickgo;
52
-
53
- /** --- Read HEAD for regression evidence, or the generated package for distribution validation. --- */
54
- async function readControlFile(name, filename) {
55
- if (sourceDirectory && name === 'select') {
56
- return await readFile(`${sourceDirectory}/${filename}`, 'utf8');
57
- }
58
- if (!useArchive) return await readFile(new URL(`../dist/sources/control/${name}/${filename}`, import.meta.url), 'utf8');
59
- const archiveName = ['page', 'property'].includes(name) ? name : 'common';
60
- if (!archives.has(archiveName)) {
61
- archives.set(archiveName, await JSZip.loadAsync(await readFile(new URL(`../dist/control/${archiveName}.cgc`, import.meta.url))));
62
- }
63
- const file = archives.get(archiveName).file(`${name}/${filename === 'code.ts' ? 'code.js' : filename}`);
64
- assert.ok(file, `packaged ${name}/${filename} must exist`);
65
- return await file.async('string');
66
- }
67
-
68
- /** --- Use the real control classes and Select/List templates; only layout and pointer scheduling are simulated. --- */
69
- async function loadControl(name) {
70
- const exports = {};
71
- const source = await readControlFile(name, 'code.ts');
72
- if (useArchive) {
73
- exports.default = vm.runInNewContext(`${source}\nemodule;`, { clickgo, Date, Map, Set });
74
- }
75
- else {
76
- vm.runInNewContext(ts.transpileModule(source, {
77
- compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
78
- }).outputText, { exports, require: () => clickgo, Date, Map, Set });
79
- }
80
- const instance = new exports.default();
81
- const prototype = getClassPrototype(instance);
82
- return {
83
- name: `cg-${name}`,
84
- template: name === 'greatlist' ?
85
- '<div class="test-greatlist"><div v-for="row, index of data" :data-value="row.value" @pointerdown="innerDown($event, index)"><slot :row="row" :index="index"></slot></div></div>' :
86
- await readControlFile(name, 'layout.html'),
87
- props: Object.fromEntries(Object.entries(instance.props).map(([key, value]) => [key, { default: () => clone(value) }])),
88
- emits: instance.emits,
89
- data: () => clone(Object.fromEntries(Object.entries(instance).filter(([key]) => !['props', 'emits'].includes(key)))),
90
- computed: prototype.access,
91
- methods: prototype.method,
92
- created() { this.props = this.$props; this.slots = this.$slots; },
93
- beforeMount() { this.onBeforeMount?.(); },
94
- async mounted() { await this.$nextTick(); this.onMounted?.(); instances.push([name, this]); },
95
- beforeUnmount() { this.onBeforeUnmount?.(); },
96
- unmounted() { this.onUnmounted?.(); },
97
- };
98
- }
99
- const components = {};
100
- for (const name of ['select', 'list', 'greatlist', 'greatselect', 'calendar', 'datepanel', 'page', 'property']) {
101
- components[`cg-${name}`] = await loadControl(name);
102
- }
103
- components['cg-text'] = {
104
- props: ['modelValue'],
105
- emits: ['update:modelValue', 'keydown', 'blur'],
106
- template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" @blur="$emit(\'blur\')">',
107
- };
108
- components['cg-marquee'] = { template: '<div><slot></slot></div>' };
109
- components['cg-button'] = { template: '<button><slot></slot></button>' };
110
- components['cg-flow'] = { template: '<div><slot></slot></div>' };
111
- for (const name of ['loading', 'icon', 'img', 'check']) {
112
- components[`cg-${name}`] = { template: '<i></i>' };
113
- }
114
- for (const name of ['menulist', 'menulist-item', 'menulist-split']) {
115
- components[`cg-${name}`] = { template: '<div><slot></slot></div>' };
116
- }
117
-
118
- /** --- Drain the real Vue render/watch order, including the Select data watcher and remote debounce. --- */
119
- async function flush() {
120
- for (let j = 0; j < 3; ++j) {
121
- for (let i = 0; i < 10; ++i) await nextTick();
122
- await new Promise(resolve => setTimeout(resolve, 0));
123
- }
124
- assert.deepEqual(errors, [], 'controls must update without runtime errors');
125
- }
126
-
127
- async function mount(props = {}, handlers = {}, name = 'select') {
128
- const state = reactive({ ...(name === 'select' ? { data: [], modelValue: [], remoteDelay: 0 } : {}), ...props });
129
- const events = [];
130
- const requests = [];
131
- const host = document.createElement('div');
132
- document.body.append(host);
133
- const app = createApp({
134
- render() {
135
- return h(components[`cg-${name}`], {
136
- ...state, ref: 'select',
137
- 'onUpdate:modelValue': value => { events.push(['value', clone(value)]); state.modelValue = value; },
138
- onLabel: labels => { events.push(['label', clone(labels)]); },
139
- onRemote: event => { requests.push(event.detail); },
140
- onChange: event => { events.push(['change', clone(event.detail)]); handlers.change?.(event); },
141
- onChanged: event => { events.push(['changed', clone(event.detail)]); },
142
- onAdd: event => { events.push(['add', clone(event.detail)]); handlers.add?.(event); },
143
- onAdded: event => { events.push(['added', clone(event.detail)]); },
144
- onRemove: event => { events.push(['remove', clone(event.detail)]); handlers.remove?.(event); },
145
- onRemoved: event => { events.push(['removed', clone(event.detail)]); },
146
- 'onUpdate:count': count => { state.count = count; },
147
- 'onUpdate:yearmonth': yearmonth => { state.yearmonth = yearmonth; },
148
- onCountchanged: count => { events.push(['countchanged', count]); },
149
- });
150
- },
151
- });
152
- for (const [name, component] of Object.entries(components)) app.component(name, component);
153
- app.config.errorHandler = error => { errors.push(error); };
154
- const root = app.mount(host);
155
- const dispose = () => { app.unmount(); host.remove(); disposers.delete(dispose); };
156
- disposers.add(dispose);
157
- await flush();
158
- const select = root.$refs.select;
159
- return {
160
- state, events, requests, select,
161
- selectors() { return instances.filter(([controlName, instance]) => controlName === 'select' && host.contains(instance.element)).map(([, instance]) => instance); },
162
- async click(index) {
163
- const gl = select.refs.list.refs.gl;
164
- gl.innerDown({ target: gl.element.children[index], shiftKey: false, ctrlKey: false }, index);
165
- await flush();
166
- },
167
- async key(key) {
168
- if (select.element.dataset.cgPopOpen === undefined) select.refs.gs.showPop();
169
- await select.textKeyDown({ key, preventDefault() {}, stopPropagation() {} });
170
- await flush();
171
- },
172
- dispose,
173
- };
174
- }
175
-
176
- const cases = [];
177
- function test(name, callback) { cases.push([name, callback]); }
178
- const option = (value, label = value, extra = {}) => ({ value, label, ...extra });
179
- const remote = { remote: true, search: true };
180
-
181
- test('remote initial value survives a nonempty unrelated list and later batches resolve its label', async () => {
182
- const m = await mount({ ...remote, modelValue: ['42'], data: [option('other')] });
183
- assert.deepEqual([...m.state.modelValue], ['42']);
184
- assert.deepEqual([...m.select.label], ['42']);
185
- m.state.data = [option('another')];
186
- await flush();
187
- assert.deepEqual([...m.state.modelValue], ['42']);
188
- m.state.data = [option('42', 'Loaded')];
189
- await flush();
190
- assert.deepEqual([...m.select.label], ['Loaded']);
191
- m.dispose();
192
- });
193
-
194
- test('remote callbacks resolve pending labels; later search pages preserve all selected tags', async () => {
195
- const m = await mount({ ...remote, multi: true, modelValue: ['42', '43'] });
196
- await m.select.updateSearchValue('first');
197
- await m.requests.at(-1).callback([option('42', 'First')]);
198
- await flush();
199
- assert.deepEqual([...m.select.label], ['First', '43']);
200
- await m.select.updateSearchValue('second');
201
- await m.requests.at(-1).callback([option('43', 'Second')]);
202
- await flush();
203
- assert.deepEqual([...m.select.label], ['First', 'Second']);
204
- assert.deepEqual([...m.state.modelValue], ['42', '43']);
205
- await m.select.updateSearchValue('');
206
- await flush();
207
- assert.deepEqual([...m.select.label], ['First', 'Second']);
208
- m.dispose();
209
- });
210
-
211
- test('editable multi labels update after late data without losing arbitrary input values', async () => {
212
- const m = await mount({ editable: true, multi: true, modelValue: ['42', 'custom'] });
213
- m.state.data = [option('42', 'Loaded')];
214
- await flush();
215
- assert.deepEqual([...m.select.label], ['Loaded', 'custom']);
216
- m.dispose();
217
- });
218
-
219
- test('local empty data defers validation, then resolves a preloaded model without choosing the first item', async () => {
220
- const m = await mount({ modelValue: ['42'] });
221
- assert.deepEqual([...m.state.modelValue], ['42']);
222
- assert.equal(m.events.filter(event => event[0] === 'value').length, 0);
223
- m.state.data = [option('other'), option('42', 'Loaded')];
224
- await flush();
225
- assert.deepEqual([...m.state.modelValue], ['42']);
226
- assert.deepEqual([...m.select.label], ['Loaded']);
227
- m.dispose();
228
- });
229
-
230
- test('local required selection still skips disabled and split rows and falls back after removal', async () => {
231
- const m = await mount({ modelValue: ['invalid'], data: [option('disabled', '', { disabled: true }), option('split', '', { control: 'split' }), option('a', 'A'), option('b', 'B')] });
232
- assert.deepEqual([...m.state.modelValue], ['a']);
233
- m.state.data = [option('b', 'B')];
234
- await flush();
235
- assert.deepEqual([...m.state.modelValue], ['b']);
236
- assert.deepEqual([...m.select.label], ['B']);
237
- m.dispose();
238
- });
239
-
240
- test('ordinary data refresh updates labels without repeating modelValue writes', async () => {
241
- const m = await mount({ data: [option('a', 'A')], modelValue: ['a'] });
242
- m.events.length = 0;
243
- m.state.data = [option('a', 'Renamed')];
244
- await flush();
245
- assert.deepEqual([...m.select.label], ['Renamed']);
246
- assert.equal(m.events.filter(event => event[0] === 'value').length, 0);
247
- m.dispose();
248
- });
249
-
250
- test('external model changes during local filtering use the complete source list', async () => {
251
- const m = await mount({ search: true, data: [option('a', 'Apple'), option('b', 'Banana')], modelValue: ['a'] });
252
- await m.select.updateSearchValue('Apple');
253
- await flush();
254
- m.state.modelValue = ['b'];
255
- await flush();
256
- assert.deepEqual([...m.state.modelValue], ['b']);
257
- assert.deepEqual([...m.select.label], ['Banana']);
258
- m.state.data = [option('a', 'Apple'), option('b', 'Renamed')];
259
- await flush();
260
- assert.deepEqual([...m.state.modelValue], ['b']);
261
- assert.deepEqual([...m.select.label], ['Renamed']);
262
- m.dispose();
263
- });
264
-
265
- test('dictionary labels and numeric labels can be searched; numeric values can be matched on blur', async () => {
266
- const m = await mount({ search: true, data: { a: 'Apple', b: 'Banana' } });
267
- await m.select.updateSearchValue('Apple');
268
- await flush();
269
- assert.deepEqual([...m.select.refs.list.dataGl.map(row => row.value)], ['a']);
270
- m.state.data = [option(42, 123)];
271
- await m.select.updateSearchValue('123');
272
- await flush();
273
- assert.equal(m.select.refs.list.dataGl.length, 1);
274
- m.state.editable = true;
275
- await flush();
276
- await m.select.updateInputValue('123');
277
- m.select.blur();
278
- await flush();
279
- assert.deepEqual([...m.state.modelValue], ['42']);
280
- m.dispose();
281
- });
282
-
283
- test('keyboard navigation in search mode does not commit a value or replace the query', async () => {
284
- const m = await mount({ search: true, data: [option('a', 'Apple'), option('b', 'Apricot')], modelValue: ['a'] });
285
- await m.select.updateSearchValue('Ap');
286
- await flush();
287
- await m.key('ArrowDown');
288
- assert.equal(m.select.searchValue, 'Ap');
289
- assert.deepEqual([...m.state.modelValue], ['a']);
290
- m.dispose();
291
- });
292
-
293
- test('local matching preserves mapped labels, primitive values and character matching across value and label', async () => {
294
- const m = await mount({ search: true, map: { value: 'id', label: 'name' }, data: [{ id: '42', name: 'Alpha' }, { id: '97', name: 'Beta' }, '24'] });
295
- await m.select.updateSearchValue('A4a');
296
- await flush();
297
- assert.deepEqual([...m.select.refs.list.dataGl.map(row => row.value)], ['42']);
298
- await m.select.updateSearchValue('42');
299
- await flush();
300
- assert.deepEqual([...m.select.refs.list.dataGl.map(row => row.value)], ['42', '24']);
301
- m.dispose();
302
- });
303
-
304
- test('mapped dictionary options retain their keys and labels during search and blur', async () => {
305
- const data = { '42': { name: 'Alpha' }, '97': { name: 'Beta' } };
306
- const m = await mount({ editable: true, search: true, map: { label: 'name' }, data });
307
- await m.select.updateInputValue('aLpHa');
308
- await flush();
309
- assert.deepEqual([...m.select.refs.list.dataGl.map(row => row.value)], ['42']);
310
- await m.select.blur();
311
- await flush();
312
- assert.deepEqual([...m.state.modelValue], ['42']);
313
- assert.deepEqual([...m.select.label], ['Alpha']);
314
- assert.deepEqual(m.state.data, data);
315
- m.dispose();
316
- });
317
-
318
- test('single local validation skips missing candidates before choosing a later valid model value', async () => {
319
- for (const search of [false, true]) {
320
- const m = await mount({ search, data: [option('a'), option('b')], modelValue: ['missing', 'b'] });
321
- assert.deepEqual([...m.state.modelValue], ['b']);
322
- m.dispose();
323
- }
324
- });
325
-
326
- test('multi search Enter adds the highlighted value instead of the query text', async () => {
327
- const m = await mount({ ...remote, multi: true });
328
- await m.select.updateSearchValue('label query');
329
- await m.requests.at(-1).callback([option('42', 'Found label')]);
330
- await flush();
331
- await m.key('ArrowDown');
332
- await m.key('Enter');
333
- assert.deepEqual([...m.state.modelValue], ['42']);
334
- assert.equal(m.events.find(event => event[0] === 'added')[1].value, '42');
335
- m.dispose();
336
- });
337
-
338
- for (const action of ['click', 'key']) {
339
- test(`search single ${action} honors change.preventDefault`, async () => {
340
- const m = await mount({ search: true, data: [option('a'), option('b')], modelValue: ['a'] }, { change: event => event.preventDefault() });
341
- m.select.listValue = ['b'];
342
- await flush();
343
- await m[action](action === 'click' ? 1 : 'Enter');
344
- assert.deepEqual([...m.state.modelValue], ['a']);
345
- assert.equal(m.events.filter(event => event[0] === 'changed').length, 0);
346
- m.dispose();
347
- });
348
- }
349
-
350
- test('editable Enter honors change.preventDefault and reports an accepted change with its previous value', async () => {
351
- let reject = true;
352
- const m = await mount({ editable: true, modelValue: ['a'] }, { change: event => { if (reject) event.preventDefault(); } });
353
- m.select.inputValue = 'b';
354
- m.select.listValue = [];
355
- await m.key('Enter');
356
- assert.deepEqual([...m.state.modelValue], ['a']);
357
- reject = false;
358
- await m.key('Enter');
359
- assert.deepEqual([...m.state.modelValue], ['b']);
360
- assert.deepEqual(m.events.find(event => event[0] === 'changed')[1], { before: ['a'], value: ['b'] });
361
- m.dispose();
362
- });
363
-
364
- test('clicks on disabled or unavailable search results do not select the previous highlighted item', async () => {
365
- const m = await mount({ search: true, data: [option('a'), option('b', 'B', { disabled: true }), option('c')], unavailableList: ['c'], modelValue: ['a'] });
366
- m.events.length = 0;
367
- await m.click(1);
368
- await m.click(2);
369
- assert.deepEqual([...m.state.modelValue], ['a']);
370
- assert.equal(m.events.filter(event => event[0] === 'changed').length, 0);
371
- m.dispose();
372
- });
373
-
374
- test('old remote requests cannot block the current spinner or overwrite newer results; callbacks are one-shot', async () => {
375
- const m = await mount(remote);
376
- await m.select.updateSearchValue('old');
377
- const old = m.requests.at(-1);
378
- await m.select.updateSearchValue('new');
379
- const current = m.requests.at(-1);
380
- await current.callback([option('new')]);
381
- await flush();
382
- assert.equal(m.select.searching, 0);
383
- await old.callback([option('old')]);
384
- await current.callback([option('duplicate')]);
385
- await flush();
386
- assert.deepEqual(m.select.searchData.map(row => row.value), ['new']);
387
- assert.equal(m.select.searching, 0);
388
- m.dispose();
389
- });
390
-
391
- test('clearing a remote query cancels its spinner and a late callback cannot modify destroyed controls', async () => {
392
- const m = await mount(remote);
393
- await m.select.updateSearchValue('pending');
394
- const request = m.requests.at(-1);
395
- await m.select.updateSearchValue('');
396
- await flush();
397
- assert.equal(m.select.searching, 0);
398
- m.dispose();
399
- await request.callback([option('late')]);
400
- assert.equal(m.select.searchData.length, 0);
401
- });
402
-
403
- test('editable multi added events retain the added value after clearing input', async () => {
404
- const m = await mount({ editable: true, multi: true });
405
- m.select.inputValue = 'custom';
406
- await m.select.keydown({ key: 'Enter', preventDefault() {}, stopPropagation() {} });
407
- await flush();
408
- assert.deepEqual([...m.state.modelValue], ['custom']);
409
- assert.equal(m.events.find(event => event[0] === 'added')[1].value, 'custom');
410
- assert.equal(m.select.inputValue, '');
411
- m.dispose();
412
- });
413
-
414
- test('numeric controlled values used by calendar/date/page/property controls normalize once and remain stable', async () => {
415
- for (const [value, data] of [[2026, [2025, 2026, 2027]], ['10', ['01', '10', '12']], [20, [10, 20, 50]]]) {
416
- const m = await mount({ modelValue: [value], data });
417
- assert.deepEqual([...m.state.modelValue], [String(value)]);
418
- const count = m.events.filter(event => event[0] === 'value').length;
419
- m.state.modelValue = [String(value)];
420
- await flush();
421
- assert.equal(m.events.filter(event => event[0] === 'value').length, count);
422
- m.dispose();
423
- }
424
- });
425
-
426
- test('updating data and modelValue in the same render keeps the supplied value', async () => {
427
- const m = await mount({ data: ['a', 'b'], modelValue: ['a'] });
428
- m.state.data = ['c', 'd'];
429
- m.state.modelValue = ['d'];
430
- await flush();
431
- assert.deepEqual([...m.state.modelValue], ['d']);
432
- assert.deepEqual([...m.select.label], ['d']);
433
- m.dispose();
434
- });
435
-
436
- for (const search of [false, true]) {
437
- test(`accepted single mouse selection reports one change and changed event (search=${search})`, async () => {
438
- const m = await mount({ search, data: [option('a'), option('b')], modelValue: ['a'] });
439
- m.events.length = 0;
440
- await m.click(1);
441
- assert.deepEqual([...m.state.modelValue], ['b']);
442
- assert.equal(m.events.filter(event => event[0] === 'change').length, 1);
443
- assert.deepEqual(m.events.find(event => event[0] === 'changed')[1], { before: ['a'], value: ['b'] });
444
- m.dispose();
445
- });
446
- }
447
-
448
- test('ordinary multi list toggles and vetoes preserve the actual selected values', async () => {
449
- let reject = false;
450
- const m = await mount({ multi: true, data: [option('a'), option('b')], modelValue: ['a'] }, {
451
- add: event => { if (reject) event.preventDefault(); },
452
- remove: event => { if (reject) event.preventDefault(); },
453
- });
454
- await m.click(1);
455
- assert.deepEqual([...m.state.modelValue], ['a', 'b']);
456
- reject = true;
457
- await m.click(1);
458
- assert.deepEqual([...m.state.modelValue], ['a', 'b']);
459
- reject = false;
460
- await m.click(0);
461
- assert.deepEqual([...m.state.modelValue], ['b']);
462
- reject = true;
463
- await m.click(0);
464
- assert.deepEqual([...m.state.modelValue], ['b']);
465
- m.dispose();
466
- });
467
-
468
- test('remote tag removal and backspace keep labels aligned; external reorder preserves cached labels', async () => {
469
- let reject = false;
470
- const m = await mount({ ...remote, editable: true, multi: true, modelValue: ['a', 'b'], data: [option('a', 'A'), option('b', 'B')] }, { remove: event => { if (reject) event.preventDefault(); } });
471
- m.state.data = [];
472
- m.state.modelValue = ['b', 'a'];
473
- await flush();
474
- assert.deepEqual([...m.select.label], ['B', 'A']);
475
- reject = true;
476
- m.select.removeTag(0);
477
- await flush();
478
- assert.deepEqual([...m.state.modelValue], ['b', 'a']);
479
- reject = false;
480
- await m.select.keydown({ key: 'Backspace', target: { value: '' } });
481
- await flush();
482
- assert.deepEqual([...m.state.modelValue], ['b']);
483
- assert.deepEqual([...m.select.label], ['B']);
484
- m.select.removeTag(0);
485
- await flush();
486
- assert.deepEqual([...m.state.modelValue], []);
487
- m.dispose();
488
- });
489
-
490
- test('disabled controls ignore keyboard, input and tag removal', async () => {
491
- const m = await mount({ ...remote, editable: true, multi: true, disabled: true, modelValue: ['a'] });
492
- m.select.removeTag(0);
493
- await m.select.keydown({ key: 'Backspace', target: { value: '' } });
494
- await m.select.updateInputValue('changed');
495
- await m.select.updateSearchValue('query');
496
- await flush();
497
- assert.deepEqual([...m.state.modelValue], ['a']);
498
- assert.equal(m.requests.length, 0);
499
- m.dispose();
500
- });
501
-
502
- for (const mode of ['tag', 'backspace', 'list']) {
503
- test(`${mode} removal preserves cancellation, event order and value/label alignment`, async () => {
504
- let reject = true;
505
- const m = await mount({ multi: true, editable: mode === 'backspace', data: [option('a', 'A'), option('b', 'B')], modelValue: ['a', 'b'] }, {
506
- remove: event => { if (reject) event.preventDefault(); },
507
- });
508
- const remove = async () => {
509
- if (mode === 'tag') m.select.removeTag(1);
510
- else if (mode === 'backspace') await m.select.keydown({ key: 'Backspace', target: { value: '' } });
511
- else await m.click(1);
512
- await flush();
513
- };
514
- m.events.length = 0;
515
- await remove();
516
- assert.deepEqual([...m.state.modelValue], ['a', 'b']);
517
- assert.deepEqual([...m.select.label], ['A', 'B']);
518
- assert.deepEqual(m.events, [['remove', { index: 1, value: 'b', mode }]]);
519
- reject = false;
520
- m.events.length = 0;
521
- await remove();
522
- assert.deepEqual([...m.state.modelValue], ['a']);
523
- assert.deepEqual([...m.select.label], ['A']);
524
- assert.deepEqual(m.events, [
525
- ['remove', { index: 1, value: 'b', mode }],
526
- ['value', ['a']],
527
- ['label', ['A']],
528
- ['removed', { index: 1, value: 'b', mode }],
529
- ]);
530
- m.dispose();
531
- });
532
- }
533
-
534
- test('map changes refresh selected labels and disabledList changes retain local validation', async () => {
535
- const m = await mount({ data: [{ id: 'a', display: 'A', name: 'Renamed' }, { id: 'b', display: 'B', name: 'Other' }], map: { value: 'id', label: 'display' }, modelValue: ['a'] });
536
- m.state.map = { value: 'id', label: 'name' };
537
- await flush();
538
- assert.deepEqual([...m.select.label], ['Renamed']);
539
- m.state.disabledList = ['a'];
540
- await flush();
541
- assert.deepEqual([...m.state.modelValue], ['b']);
542
- m.dispose();
543
- });
544
-
545
- test('tree selection opens the selected child and retains child labels during filtering', async () => {
546
- const m = await mount({ tree: true, search: true, modelValue: ['child'], data: [option('parent', 'Parent', { children: [option('child', 'Child')] }), option('other', 'Other')] });
547
- assert.deepEqual([...m.select.label], ['Child']);
548
- await m.select.updateSearchValue('Other');
549
- await flush();
550
- m.state.modelValue = ['child'];
551
- m.state.data = [option('parent', 'Parent', { children: [option('child', 'Renamed')] }), option('other', 'Other')];
552
- await flush();
553
- assert.deepEqual([...m.state.modelValue], ['child']);
554
- assert.deepEqual([...m.select.label], ['Renamed']);
555
- m.dispose();
556
- });
557
-
558
- test('mode changes preserve remote unknown values, then enforce local validation', async () => {
559
- const m = await mount({ ...remote, editable: true, multi: true, modelValue: ['unknown', 'a'], data: [option('a', 'A')] });
560
- m.state.editable = false;
561
- await flush();
562
- assert.deepEqual([...m.state.modelValue], ['unknown', 'a']);
563
- m.state.multi = false;
564
- await flush();
565
- assert.deepEqual([...m.state.modelValue], ['unknown']);
566
- m.state.remote = false;
567
- await flush();
568
- assert.deepEqual([...m.state.modelValue], []);
569
- m.state.search = false;
570
- await flush();
571
- assert.deepEqual([...m.state.modelValue], ['a']);
572
- m.dispose();
573
- });
574
-
575
- test('search and metadata toggles do not discard a multi editor input in progress', async () => {
576
- const m = await mount({ editable: true, multi: true, data: [option('a')], modelValue: ['a'] });
577
- await m.select.updateInputValue('draft');
578
- m.state.search = true;
579
- await flush();
580
- assert.equal(m.select.inputValue, 'draft');
581
- m.state.disabledList = ['a'];
582
- await flush();
583
- assert.equal(m.select.inputValue, 'draft');
584
- assert.deepEqual([...m.state.modelValue], ['a']);
585
- m.dispose();
586
- });
587
-
588
- test('external editable values invalidate pending searches for the previous input', async () => {
589
- const m = await mount({ ...remote, editable: true });
590
- await m.select.updateInputValue('old');
591
- const request = m.requests.at(-1);
592
- m.state.modelValue = ['new'];
593
- await flush();
594
- await request.callback([option('old', 'Old label')]);
595
- await flush();
596
- assert.deepEqual([...m.state.modelValue], ['new']);
597
- assert.deepEqual([...m.select.label], ['new']);
598
- assert.equal(m.select.searchData.length, 0);
599
- m.dispose();
600
- });
601
-
602
- test('remote requests that are superseded during nextTick cannot run their old success handler', async () => {
603
- const m = await mount(remote);
604
- await m.select.updateSearchValue('old');
605
- const old = m.requests.at(-1);
606
- const callback = old.callback([option('old')]);
607
- const search = m.select.updateSearchValue('new');
608
- await Promise.all([callback, search]);
609
- await m.requests.at(-1).callback([option('new')]);
610
- await flush();
611
- assert.deepEqual([...m.select.listValue], ['new']);
612
- assert.deepEqual([...m.select.searchData.map(row => row.value)], ['new']);
613
- m.dispose();
614
- });
615
-
616
- test('debounce dispatches only the newest query and unmount cancels a queued dispatch', async () => {
617
- const m = await mount({ ...remote, remoteDelay: 10 });
618
- await Promise.all([m.select.updateSearchValue('old'), m.select.updateSearchValue('new')]);
619
- assert.deepEqual(m.requests.map(request => request.value), ['new']);
620
- const queued = m.select.updateSearchValue('queued');
621
- m.dispose();
622
- await queued;
623
- assert.equal(m.requests.length, 1);
624
- });
625
-
626
- test('editable remote debounce commits only the latest typed value and emits changed once', async () => {
627
- const m = await mount({ ...remote, editable: true, remoteDelay: 10 });
628
- m.events.length = 0;
629
- await Promise.all([m.select.updateInputValue('old'), m.select.updateInputValue('new')]);
630
- await flush();
631
- assert.deepEqual([...m.state.modelValue], ['new']);
632
- assert.deepEqual(m.events.filter(event => event[0] === 'changed').map(event => event[1]), [{ before: [], value: ['new'] }]);
633
- assert.deepEqual(m.requests.map(request => request.value), ['new']);
634
- m.dispose();
635
- });
636
-
637
- test('returning to the first remote query still commits only the latest editable input', async () => {
638
- const m = await mount({ ...remote, editable: true, remoteDelay: 10, modelValue: ['start'] });
639
- m.events.length = 0;
640
- await Promise.all(['old', 'new', 'old'].map(value => m.select.updateInputValue(value)));
641
- await flush();
642
- assert.deepEqual([...m.state.modelValue], ['old']);
643
- assert.deepEqual(m.events.filter(event => event[0] === 'value').map(event => event[1]), [['old']]);
644
- assert.deepEqual(
645
- m.events.filter(event => event[0] === 'changed').map(event => event[1]),
646
- [{ before: ['start'], value: ['old'] }]
647
- );
648
- assert.deepEqual(m.requests.map(request => request.value), ['old']);
649
- await m.requests[0].callback([option('old', 'Old')]);
650
- await flush();
651
- assert.deepEqual([...m.select.label], ['Old']);
652
- assert.equal(m.events.filter(event => event[0] === 'changed').length, 1);
653
- m.dispose();
654
- });
655
-
656
- for (const search of [false, true]) {
657
- for (const values of [['old', 'new'], ['old', 'new', 'old']]) {
658
- test(`editable input commits only the latest value and label (search=${search}, inputs=${values.join(',')})`, async () => {
659
- const m = await mount({
660
- editable: true,
661
- search,
662
- modelValue: ['start'],
663
- data: [option('start', 'Start'), option('old', 'Old'), option('new', 'New')],
664
- });
665
- m.events.length = 0;
666
- await Promise.all(values.map(value => m.select.updateInputValue(value)));
667
- await flush();
668
- const value = values.at(-1);
669
- assert.deepEqual([...m.state.modelValue], [value]);
670
- assert.deepEqual([...m.select.label], [value === 'old' ? 'Old' : 'New']);
671
- assert.deepEqual(m.events.filter(event => event[0] === 'value').map(event => event[1]), [[value]]);
672
- assert.deepEqual(
673
- m.events.filter(event => event[0] === 'changed').map(event => event[1]),
674
- [{ before: ['start'], value: [value] }]
675
- );
676
- m.dispose();
677
- });
678
- }
679
-
680
- test(`clearing editable input supersedes a pending value without committing it (search=${search})`, async () => {
681
- const m = await mount({
682
- editable: true,
683
- search,
684
- modelValue: ['start'],
685
- data: [option('start', 'Start'), option('old', 'Old')],
686
- });
687
- m.events.length = 0;
688
- await Promise.all([m.select.updateInputValue('old'), m.select.updateInputValue('')]);
689
- await flush();
690
- assert.deepEqual([...m.state.modelValue], []);
691
- assert.deepEqual([...m.select.label], []);
692
- assert.deepEqual(m.events.filter(event => event[0] === 'value').map(event => event[1]), [[]]);
693
- assert.deepEqual(
694
- m.events.filter(event => event[0] === 'changed').map(event => event[1]),
695
- [{ before: ['start'], value: [] }]
696
- );
697
- m.dispose();
698
- });
699
- }
700
-
701
- test('rejecting a newer input keeps the previous accepted input pending', async () => {
702
- const m = await mount({
703
- editable: true,
704
- modelValue: ['start'],
705
- data: [option('start', 'Start'), option('old', 'Old')],
706
- }, {
707
- change: event => {
708
- if (event.detail.value[0] === 'new') {
709
- event.preventDefault();
710
- }
711
- },
712
- });
713
- m.events.length = 0;
714
- await Promise.all([m.select.updateInputValue('old'), m.select.updateInputValue('new')]);
715
- await flush();
716
- assert.deepEqual([...m.state.modelValue], ['old']);
717
- assert.deepEqual([...m.select.label], ['Old']);
718
- assert.equal(m.select.inputValue, 'old');
719
- assert.deepEqual(m.events.filter(event => event[0] === 'value').map(event => event[1]), [['old']]);
720
- assert.deepEqual(
721
- m.events.filter(event => event[0] === 'changed').map(event => event[1]),
722
- [{ before: ['start'], value: ['old'] }]
723
- );
724
- m.dispose();
725
- });
726
-
727
- test('editable inputs preserve zero, numeric and empty labels as strings', async () => {
728
- for (const label of [0, 123, '']) {
729
- const m = await mount({ editable: true, data: [option('42', label)] });
730
- m.events.length = 0;
731
- await m.select.updateInputValue('42');
732
- await flush();
733
- assert.deepEqual([...m.state.modelValue], ['42']);
734
- assert.deepEqual([...m.select.label], [String(label)]);
735
- assert.deepEqual(m.events.filter(event => event[0] === 'label').at(-1)[1], [String(label)]);
736
- m.dispose();
737
- }
738
- });
739
-
740
- test('an external model replacement supersedes editable input awaiting its label', async () => {
741
- const m = await mount({
742
- editable: true,
743
- modelValue: ['start'],
744
- data: [option('start', 'Start'), option('draft', 'Draft'), option('external', 'External')],
745
- });
746
- m.events.length = 0;
747
- const input = m.select.updateInputValue('draft');
748
- m.state.modelValue = ['external'];
749
- await input;
750
- await flush();
751
- assert.deepEqual([...m.state.modelValue], ['external']);
752
- assert.deepEqual([...m.select.value], ['external']);
753
- assert.deepEqual([...m.select.label], ['External']);
754
- assert.equal(m.select.inputValue, 'external');
755
- assert.equal(m.events.filter(event => event[0] === 'value' || event[0] === 'changed').length, 0);
756
- m.dispose();
757
- });
758
-
759
- test('switching to multi mode supersedes a pending single editable input', async () => {
760
- const m = await mount({ editable: true, modelValue: ['start'], data: [option('start'), option('draft')] });
761
- m.events.length = 0;
762
- const input = m.select.updateInputValue('draft');
763
- m.state.multi = true;
764
- await input;
765
- await flush();
766
- assert.deepEqual([...m.state.modelValue], ['start']);
767
- assert.deepEqual([...m.select.value], ['start']);
768
- assert.equal(m.select.inputValue, '');
769
- assert.equal(m.events.filter(event => event[0] === 'value' || event[0] === 'changed').length, 0);
770
- m.dispose();
771
- });
772
-
773
- test('selecting a pending editable value commits it once with the previous selection', async () => {
774
- const m = await mount({
775
- editable: true,
776
- modelValue: ['start'],
777
- data: [option('start', 'Start'), option('new', 'New')],
778
- });
779
- m.events.length = 0;
780
- const input = m.select.updateInputValue('new');
781
- await m.click(1);
782
- await input;
783
- await flush();
784
- assert.deepEqual([...m.state.modelValue], ['new']);
785
- assert.deepEqual([...m.select.label], ['New']);
786
- assert.deepEqual(m.events.filter(event => event[0] === 'value').map(event => event[1]), [['new']]);
787
- assert.deepEqual(
788
- m.events.filter(event => event[0] === 'changed').map(event => event[1]),
789
- [{ before: ['start'], value: ['new'] }]
790
- );
791
- m.dispose();
792
- });
793
-
794
- test('unmounting supersedes editable input awaiting its label', async () => {
795
- const m = await mount({ editable: true, modelValue: ['start'], data: [option('start'), option('draft')] });
796
- m.events.length = 0;
797
- const input = m.select.updateInputValue('draft');
798
- m.dispose();
799
- await input;
800
- assert.deepEqual([...m.select.value], ['start']);
801
- assert.deepEqual([...m.state.modelValue], ['start']);
802
- assert.equal(m.events.filter(event => event[0] === 'value' || event[0] === 'changed').length, 0);
803
- });
804
-
805
- test('blur canonicalization honors the change veto instead of silently replacing an entered label', async () => {
806
- const m = await mount({ editable: true, data: [option('42', 'Forty')] }, { change: event => { if (event.detail.value[0] === '42') event.preventDefault(); } });
807
- await m.select.updateInputValue('Forty');
808
- m.select.blur();
809
- await flush();
810
- assert.deepEqual([...m.state.modelValue], ['Forty']);
811
- m.dispose();
812
- });
813
-
814
- test('plain List keeps its own missing-value filtering contract', async () => {
815
- const host = document.createElement('div');
816
- document.body.append(host);
817
- const state = reactive({ modelValue: ['missing'] });
818
- const app = createApp({ render: () => h(components['cg-list'], { data: ['a'], must: false, modelValue: state.modelValue, 'onUpdate:modelValue': value => { state.modelValue = value; } }) });
819
- for (const [name, component] of Object.entries(components)) app.component(name, component);
820
- app.config.errorHandler = error => { errors.push(error); };
821
- app.mount(host);
822
- await flush();
823
- assert.deepEqual([...state.modelValue], []);
824
- app.unmount();
825
- host.remove();
826
- });
827
-
828
- test('Page uses Select to change page size exactly once and recomputes the page count', async () => {
829
- const m = await mount({ modelValue: 1, count: 20, counts: [10, 20, 50], total: 100 }, {}, 'page');
830
- assert.deepEqual([...m.select.countSelect], ['20']);
831
- assert.equal(m.select.maxPage, 5);
832
- const select = m.selectors()[0];
833
- select.refs.list.refs.gl.innerDown({ target: select.refs.list.refs.gl.element.children[2], shiftKey: false, ctrlKey: false }, 2);
834
- await flush();
835
- assert.equal(m.state.count, 50);
836
- assert.equal(m.select.maxPage, 2);
837
- assert.equal(m.events.filter(event => event[0] === 'countchanged').length, 1);
838
- m.dispose();
839
- });
840
-
841
- for (const name of ['calendar', 'datepanel']) {
842
- test(`${name} retains its selected year/month after its Select lists refresh`, async () => {
843
- const m = await mount({ yearmonth: '202610' }, {}, name);
844
- await flush();
845
- assert.deepEqual([...m.select.vyear], ['2026']);
846
- assert.deepEqual([...m.select.vmonth], ['10']);
847
- assert.equal(m.selectors().length >= 2, true);
848
- m.state.yearmonth = '202701';
849
- await flush();
850
- assert.deepEqual([...m.select.vyear], ['2027']);
851
- assert.deepEqual([...m.select.vmonth], [name === 'calendar' ? '01' : '1']);
852
- m.dispose();
853
- });
854
- }
855
-
856
- test('Property uses inline modelValue arrays without recursive writes and accepts a choice', async () => {
857
- const m = await mount({ modelValue: [{ title: 'Choice', control: 'select', value: 'b', data: ['a', 'b', 'c'] }] }, {}, 'property');
858
- m.select.selectedTitle = 'Choice';
859
- await flush();
860
- const select = m.selectors()[0];
861
- assert.deepEqual([...select.value], ['b']);
862
- select.refs.list.refs.gl.innerDown({ target: select.refs.list.refs.gl.element.children[2], shiftKey: false, ctrlKey: false }, 2);
863
- await flush();
864
- assert.equal(m.state.modelValue[0].value, 'c');
865
- m.dispose();
866
- });
867
-
868
- let failures = 0;
869
- for (const [name, callback] of cases) {
870
- try {
871
- await callback();
872
- console.log(`PASS ${name}`);
873
- }
874
- catch (error) {
875
- ++failures;
876
- console.error(`FAIL ${name}\n${error.stack}`);
877
- errors.length = 0;
878
- }
879
- finally {
880
- for (const dispose of disposers) dispose();
881
- instances.length = 0;
882
- }
883
- }
884
- console.log(`${cases.length - failures}/${cases.length} Select integration cases passed.`);
885
- if (failures) process.exitCode = 1;