haori 0.6.2 → 0.8.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 (47) hide show
  1. package/dist/haori.cjs.js +11 -11
  2. package/dist/haori.cjs.js.map +1 -1
  3. package/dist/haori.es.js +751 -650
  4. package/dist/haori.es.js.map +1 -1
  5. package/dist/haori.iife.js +11 -11
  6. package/dist/haori.iife.js.map +1 -1
  7. package/dist/index.d.ts +46 -2
  8. package/dist/package.json +1 -1
  9. package/dist/src/core.d.ts +45 -1
  10. package/dist/src/core.d.ts.map +1 -1
  11. package/dist/src/core.js +103 -10
  12. package/dist/src/core.js.map +1 -1
  13. package/dist/src/event.d.ts +12 -0
  14. package/dist/src/event.d.ts.map +1 -1
  15. package/dist/src/event.js +14 -0
  16. package/dist/src/event.js.map +1 -1
  17. package/dist/src/index.d.ts +1 -1
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +1 -1
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/procedure.d.ts +7 -0
  22. package/dist/src/procedure.d.ts.map +1 -1
  23. package/dist/src/procedure.js +58 -4
  24. package/dist/src/procedure.js.map +1 -1
  25. package/dist/tests/data-bind-arg-reeval.test.d.ts +2 -0
  26. package/dist/tests/data-bind-arg-reeval.test.d.ts.map +1 -0
  27. package/dist/tests/data-bind-arg-reeval.test.js +119 -0
  28. package/dist/tests/data-bind-arg-reeval.test.js.map +1 -0
  29. package/dist/tests/data-bind-merge.test.d.ts +2 -0
  30. package/dist/tests/data-bind-merge.test.d.ts.map +1 -0
  31. package/dist/tests/data-bind-merge.test.js +86 -0
  32. package/dist/tests/data-bind-merge.test.js.map +1 -0
  33. package/dist/tests/data-if-falsy.test.d.ts +2 -0
  34. package/dist/tests/data-if-falsy.test.d.ts.map +1 -0
  35. package/dist/tests/data-if-falsy.test.js +73 -0
  36. package/dist/tests/data-if-falsy.test.js.map +1 -0
  37. package/dist/tests/data-load-on-show.test.d.ts +2 -0
  38. package/dist/tests/data-load-on-show.test.d.ts.map +1 -0
  39. package/dist/tests/data-load-on-show.test.js +98 -0
  40. package/dist/tests/data-load-on-show.test.js.map +1 -0
  41. package/dist/tests/each-update-event.test.d.ts +2 -0
  42. package/dist/tests/each-update-event.test.d.ts.map +1 -0
  43. package/dist/tests/each-update-event.test.js +83 -0
  44. package/dist/tests/each-update-event.test.js.map +1 -0
  45. package/dist/tests/procedure-action-operations.test.js +128 -0
  46. package/dist/tests/procedure-action-operations.test.js.map +1 -1
  47. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-bind-merge.test.d.ts","sourceRoot":"","sources":["../../tests/data-bind-merge.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,86 @@
1
+ /* @vitest-environment jsdom */
2
+ /**
3
+ * @fileoverview
4
+ * data-{event}-bind-merge による浅いマージバインドの統合テストです。
5
+ * bind-merge 指定時はバインド先の既存 binding data を保持したまま、
6
+ * 解決済みデータで上書きすることを確認します。
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import Core from '../src/core';
10
+ import EventDispatcher from '../src/event_dispatcher';
11
+ import { waitForCondition, waitForDomSettled } from './helpers/async';
12
+ describe('data-bind-merge(浅いマージバインド)', () => {
13
+ let container;
14
+ let dispatcher;
15
+ beforeEach(() => {
16
+ vi.restoreAllMocks();
17
+ container = document.createElement('div');
18
+ document.body.appendChild(container);
19
+ dispatcher = new EventDispatcher(document);
20
+ dispatcher.start();
21
+ });
22
+ afterEach(() => {
23
+ dispatcher.stop();
24
+ vi.restoreAllMocks();
25
+ document.body.removeChild(container);
26
+ });
27
+ it('bind-merge 指定時は既存キー(items)を保持しつつ selectedId を上書きする', async () => {
28
+ // items 読み込み後にボタンが表示され、data-load-* で selectedId を #state へ
29
+ // マージする。bind-merge により items は消えない。
30
+ container.innerHTML = `
31
+ <div id="state" data-bind='{"items":[],"selectedId":null}'>
32
+ <button
33
+ id="auto"
34
+ type="button"
35
+ data-if="items.length > 0 && !selectedId"
36
+ data-load-data="selectedId={{items[0]?.id}}"
37
+ data-load-bind="#state"
38
+ data-load-bind-merge
39
+ >自動選択</button>
40
+ </div>
41
+ `;
42
+ const state = container.querySelector('#state');
43
+ await Core.scan(container);
44
+ await waitForDomSettled();
45
+ await Core.setBindingData(state, {
46
+ items: [{ id: 1, name: 'A' }, { id: 2, name: 'B' }],
47
+ selectedId: null,
48
+ });
49
+ await waitForCondition(() => {
50
+ const bind = state.getAttribute('data-bind');
51
+ return bind !== null && JSON.parse(bind).selectedId != null;
52
+ }, { description: 'data-load-bind-merge で selectedId が反映される' });
53
+ const bind = JSON.parse(state.getAttribute('data-bind'));
54
+ // selectedId は反映され、items は保持される。
55
+ expect(String(bind.selectedId)).toBe('1');
56
+ expect(Array.isArray(bind.items)).toBe(true);
57
+ expect(bind.items).toHaveLength(2);
58
+ });
59
+ it('bind-merge を指定しない場合は従来どおり全置換でキーが消える', async () => {
60
+ container.innerHTML = `
61
+ <div id="src" data-bind='{"value":"x"}'>
62
+ <button
63
+ id="btn"
64
+ type="button"
65
+ data-click-data="picked=1"
66
+ data-click-bind="#dest"
67
+ >set</button>
68
+ </div>
69
+ <div id="dest" data-bind='{"keep":"yes","picked":null}'></div>
70
+ `;
71
+ const dest = container.querySelector('#dest');
72
+ const button = container.querySelector('#btn');
73
+ await Core.scan(container);
74
+ await waitForDomSettled();
75
+ button.click();
76
+ await waitForCondition(() => {
77
+ const bind = dest.getAttribute('data-bind');
78
+ return bind !== null && JSON.parse(bind).picked != null;
79
+ }, { description: 'bind で picked が反映される' });
80
+ const bind = JSON.parse(dest.getAttribute('data-bind'));
81
+ // 全置換のため keep は消える(従来挙動)。
82
+ expect(String(bind.picked)).toBe('1');
83
+ expect('keep' in bind).toBe(false);
84
+ });
85
+ });
86
+ //# sourceMappingURL=data-bind-merge.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-bind-merge.test.js","sourceRoot":"","sources":["../../tests/data-bind-merge.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=data-if-falsy.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-if-falsy.test.d.ts","sourceRoot":"","sources":["../../tests/data-if-falsy.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,73 @@
1
+ /* @vitest-environment jsdom */
2
+ /**
3
+ * @fileoverview
4
+ * data-if の非表示判定が JavaScript の falsy 準拠であることを検証するテストです。
5
+ * `0` や空文字列 `''` は非表示、空配列 `[]` などのオブジェクトは表示されることを確認します。
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import Core from '../src/core';
9
+ import { waitForDomSettled } from './helpers/async';
10
+ describe('data-if の falsy 判定', () => {
11
+ let container;
12
+ beforeEach(() => {
13
+ vi.restoreAllMocks();
14
+ container = document.createElement('div');
15
+ document.body.appendChild(container);
16
+ });
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ document.body.removeChild(container);
20
+ });
21
+ /**
22
+ * 指定 id の要素が非表示(data-if-false 付き)かどうかを返します。
23
+ *
24
+ * @param id 要素 id
25
+ * @return 非表示なら true
26
+ */
27
+ const isHidden = (id) => container.querySelector(`#${id}`).hasAttribute('data-if-false');
28
+ it('数値 0(items.length)は非表示、要素があれば表示になる', async () => {
29
+ container.innerHTML = `
30
+ <div id="root" data-bind='{"items":[]}'>
31
+ <p id="len" data-if="items.length">件あり</p>
32
+ </div>
33
+ `;
34
+ const root = container.querySelector('#root');
35
+ await Core.scan(root);
36
+ await waitForDomSettled();
37
+ // items.length === 0 → falsy → 非表示
38
+ expect(isHidden('len')).toBe(true);
39
+ await Core.setBindingData(root, { items: [{ id: 1 }] });
40
+ await waitForDomSettled();
41
+ // items.length === 1 → truthy → 表示
42
+ expect(isHidden('len')).toBe(false);
43
+ });
44
+ it('空文字列は非表示、非空文字列は表示になる(存在チェック)', async () => {
45
+ container.innerHTML = `
46
+ <div id="root2" data-bind='{"message":""}'>
47
+ <p id="msg" data-if="message">{{message}}</p>
48
+ </div>
49
+ `;
50
+ const root = container.querySelector('#root2');
51
+ await Core.scan(root);
52
+ await waitForDomSettled();
53
+ expect(isHidden('msg')).toBe(true);
54
+ await Core.setBindingData(root, { message: 'こんにちは' });
55
+ await waitForDomSettled();
56
+ expect(isHidden('msg')).toBe(false);
57
+ });
58
+ it('空配列・空オブジェクトは truthy として表示される', async () => {
59
+ container.innerHTML = `
60
+ <div id="root3" data-bind='{"list":[],"obj":{}}'>
61
+ <p id="arr" data-if="list">配列あり</p>
62
+ <p id="o" data-if="obj">オブジェクトあり</p>
63
+ </div>
64
+ `;
65
+ const root = container.querySelector('#root3');
66
+ await Core.scan(root);
67
+ await waitForDomSettled();
68
+ // [] も {} も JavaScript では truthy なので表示
69
+ expect(isHidden('arr')).toBe(false);
70
+ expect(isHidden('o')).toBe(false);
71
+ });
72
+ });
73
+ //# sourceMappingURL=data-if-falsy.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-if-falsy.test.js","sourceRoot":"","sources":["../../tests/data-if-falsy.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=data-load-on-show.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-load-on-show.test.d.ts","sourceRoot":"","sources":["../../tests/data-load-on-show.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,98 @@
1
+ /* @vitest-environment jsdom */
2
+ /**
3
+ * @fileoverview
4
+ * data-if による表示(haori:show)を契機に data-load-* が発火することを検証する
5
+ * 統合テストです。ボタンなどネイティブの load イベントが発生しない要素でも、
6
+ * 非表示→表示への遷移時に data-load-* 手続きが1回だけ実行されることを確認します。
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import Core from '../src/core';
10
+ import Procedure from '../src/procedure';
11
+ import { waitForCondition, waitForDomSettled } from './helpers/async';
12
+ describe('data-load-* の data-if 表示連動発火', () => {
13
+ let container;
14
+ beforeEach(() => {
15
+ vi.restoreAllMocks();
16
+ container = document.createElement('div');
17
+ document.body.appendChild(container);
18
+ });
19
+ afterEach(() => {
20
+ vi.restoreAllMocks();
21
+ document.body.removeChild(container);
22
+ });
23
+ it('非表示の data-if 要素が表示に遷移したとき data-load-* が発火する', async () => {
24
+ // #result は data-load-* の出力先。#state の items が読み込まれて
25
+ // ボタンが表示されたタイミングで data-load-* が発火し、#result へ反映される。
26
+ container.innerHTML = `
27
+ <div id="result" data-bind='{"picked":null}'></div>
28
+ <div id="state" data-bind='{"items":[]}'>
29
+ <button
30
+ id="auto"
31
+ type="button"
32
+ data-if="items.length > 0"
33
+ data-load-data="picked={{items[0]?.id}}"
34
+ data-load-bind="#result"
35
+ >自動選択</button>
36
+ </div>
37
+ `;
38
+ const state = container.querySelector('#state');
39
+ const result = container.querySelector('#result');
40
+ const button = container.querySelector('#auto');
41
+ await Core.scan(container);
42
+ await waitForDomSettled();
43
+ // items が空なので data-if は false。要素は非表示で data-load-* は未発火。
44
+ expect(button.hasAttribute('data-if-false')).toBe(true);
45
+ expect(JSON.parse(result.getAttribute('data-bind')).picked).toBe(null);
46
+ // 後から items を投入する(reactive な data-bind 更新)。
47
+ await Core.setBindingData(state, { items: [{ id: 1 }, { id: 2 }] });
48
+ // 非表示→表示への遷移で data-load-* が発火し、#result へ picked が反映される。
49
+ await waitForCondition(() => {
50
+ const bind = result.getAttribute('data-bind');
51
+ return bind !== null && JSON.parse(bind).picked != null;
52
+ }, { description: 'data-load-* が #result へ picked を反映する' });
53
+ expect(button.hasAttribute('data-if-false')).toBe(false);
54
+ const bind = JSON.parse(result.getAttribute('data-bind'));
55
+ expect(String(bind.picked)).toBe('1');
56
+ });
57
+ it('表示状態のまま再評価しても data-load-* を再発火しない', async () => {
58
+ container.innerHTML = `
59
+ <div id="result2" data-bind='{"count":0}'></div>
60
+ <div id="state2" data-bind='{"items":[{"id":1}],"tick":0}'>
61
+ <button
62
+ id="auto2"
63
+ type="button"
64
+ data-if="items.length > 0"
65
+ data-load-data="hit=1"
66
+ data-load-bind="#result2"
67
+ >x</button>
68
+ </div>
69
+ `;
70
+ const state = container.querySelector('#state2');
71
+ await Core.scan(container);
72
+ await waitForDomSettled();
73
+ const runSpy = vi.spyOn(Procedure.prototype, 'run');
74
+ // 表示状態を保ったまま無関係な値だけを更新して再評価を促す。
75
+ await Core.setBindingData(state, { items: [{ id: 1 }], tick: 1 });
76
+ await waitForDomSettled();
77
+ await Core.setBindingData(state, { items: [{ id: 1 }], tick: 2 });
78
+ await waitForDomSettled();
79
+ // すでに表示済み(遷移なし)のため load Procedure は再発火しない。
80
+ expect(runSpy).not.toHaveBeenCalled();
81
+ });
82
+ it('data-load-* を持たない data-if 要素では load Procedure を起動しない', async () => {
83
+ container.innerHTML = `
84
+ <div id="state3" data-bind='{"flag":false}'>
85
+ <span id="plain" data-if="flag">表示</span>
86
+ </div>
87
+ `;
88
+ const state = container.querySelector('#state3');
89
+ await Core.scan(container);
90
+ await waitForDomSettled();
91
+ const runSpy = vi.spyOn(Procedure.prototype, 'run');
92
+ await Core.setBindingData(state, { flag: true });
93
+ await waitForDomSettled();
94
+ // data-load-* を持たないため load 種別の Procedure は1件も起動されない。
95
+ expect(runSpy).not.toHaveBeenCalled();
96
+ });
97
+ });
98
+ //# sourceMappingURL=data-load-on-show.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"data-load-on-show.test.js","sourceRoot":"","sources":["../../tests/data-load-on-show.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=each-update-event.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"each-update-event.test.d.ts","sourceRoot":"","sources":["../../tests/each-update-event.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,83 @@
1
+ /* @vitest-environment jsdom */
2
+ /**
3
+ * @fileoverview
4
+ * haori:eachupdate イベントが「data-each の全行の描画完了後」に発火することを保証する
5
+ * 回帰テストです。イベント発火時点で全行が DOM に存在し、{{...}} が補間済みであることを
6
+ * 確認します。外部からの描画完了検知の契約として固定します。
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+ import Core from '../src/core';
10
+ import { waitForCondition, waitForDomSettled } from './helpers/async';
11
+ describe('haori:eachupdate 発火タイミングの保証', () => {
12
+ let container;
13
+ beforeEach(() => {
14
+ vi.restoreAllMocks();
15
+ container = document.createElement('div');
16
+ document.body.appendChild(container);
17
+ });
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ document.body.removeChild(container);
21
+ });
22
+ it('eachupdate 発火時点で全行が DOM に存在し補間済みである(25行)', async () => {
23
+ const rows = Array.from({ length: 25 }, (_, i) => ({ id: i, label: `M${i}` }));
24
+ container.innerHTML = `
25
+ <div id="state" data-bind='{"rows":[]}'>
26
+ <table><tbody>
27
+ <tr data-each="rows" data-each-key="id" data-each-arg="row">
28
+ <td class="lbl">{{row.label}}</td>
29
+ <td><input name="value" type="number"></td>
30
+ </tr>
31
+ </tbody></table>
32
+ </div>`;
33
+ const state = container.querySelector('#state');
34
+ const tbody = container.querySelector('tbody');
35
+ // eachupdate 発火時点の DOM スナップショットを記録する。
36
+ const snapshots = [];
37
+ tbody.addEventListener('haori:eachupdate', (e) => {
38
+ const detail = e.detail;
39
+ const labels = Array.from(container.querySelectorAll('.lbl'));
40
+ snapshots.push({
41
+ total: detail.total,
42
+ rendered: labels.length,
43
+ allFilled: labels.every(el => el.textContent !== '' && !el.textContent.includes('{{')),
44
+ });
45
+ });
46
+ await Core.scan(container);
47
+ await waitForDomSettled();
48
+ await Core.setBindingData(state, { rows });
49
+ await waitForCondition(() => container.querySelectorAll('.lbl').length === 25, { description: '25 行描画', maxAttempts: 40, delayMs: 50 });
50
+ // total=25 の eachupdate が発火し、その時点で 25 行すべてが補間済みであること。
51
+ const completed = snapshots.find(s => s.total === 25);
52
+ expect(completed, 'total=25 の eachupdate が発火していない').toBeTruthy();
53
+ expect(completed.rendered).toBe(25);
54
+ expect(completed.allFilled).toBe(true);
55
+ });
56
+ it('eachupdate の detail は added / removed / order / total を提供する', async () => {
57
+ container.innerHTML = `
58
+ <div id="state2" data-bind='{"rows":[{"id":1},{"id":2}]}'>
59
+ <table><tbody>
60
+ <tr data-each="rows" data-each-key="id" data-each-arg="r" class="row"><td>{{r.id}}</td></tr>
61
+ </tbody></table>
62
+ </div>`;
63
+ const state = container.querySelector('#state2');
64
+ const tbody = container.querySelector('tbody');
65
+ await Core.scan(container);
66
+ await waitForDomSettled();
67
+ const events = [];
68
+ tbody.addEventListener('haori:eachupdate', (e) => {
69
+ events.push(e.detail);
70
+ });
71
+ // 1 を削除し 3, 4 を追加する。
72
+ await Core.setBindingData(state, { rows: [{ id: 2 }, { id: 3 }, { id: 4 }] });
73
+ await waitForCondition(() => events.length > 0, {
74
+ description: 'eachupdate 発火',
75
+ });
76
+ const detail = events[events.length - 1];
77
+ expect(detail.total).toBe(3);
78
+ expect(detail.order).toEqual(['2', '3', '4']);
79
+ expect(detail.added).toEqual(['3', '4']);
80
+ expect(detail.removed).toEqual(['1']);
81
+ });
82
+ });
83
+ //# sourceMappingURL=each-update-event.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"each-update-event.test.js","sourceRoot":"","sources":["../../tests/each-update-event.test.ts"],"names":[],"mappings":"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"}
@@ -189,6 +189,134 @@ describe('Procedure action operations', () => {
189
189
  errorSpy.mockRestore();
190
190
  container.remove();
191
191
  });
192
+ it('data-click-copy-source copies from specified form to destination', async () => {
193
+ const container = document.createElement('div');
194
+ document.body.appendChild(container);
195
+ const sourceForm = document.createElement('form');
196
+ sourceForm.id = 'copy-source-specified-form';
197
+ const keywordInput = document.createElement('input');
198
+ keywordInput.name = 'keyword';
199
+ keywordInput.value = 'from-source';
200
+ const pageInput = document.createElement('input');
201
+ pageInput.name = 'page';
202
+ pageInput.value = '5';
203
+ sourceForm.append(keywordInput, pageInput);
204
+ const destForm = document.createElement('form');
205
+ destForm.id = 'copy-dest-specified-form';
206
+ const destKeyword = document.createElement('input');
207
+ destKeyword.name = 'keyword';
208
+ const destPage = document.createElement('input');
209
+ destPage.name = 'page';
210
+ destForm.append(destKeyword, destPage);
211
+ const btn = document.createElement('button');
212
+ btn.setAttribute('data-click-copy-source', '#copy-source-specified-form');
213
+ btn.setAttribute('data-click-copy', '#copy-dest-specified-form');
214
+ container.append(sourceForm, destForm, btn);
215
+ await waitForDomSettled();
216
+ btn.click();
217
+ await waitForCondition(() => destKeyword.value === 'from-source' && destPage.value === '5', { description: 'copy from specified source form' });
218
+ expect(destKeyword.value).toBe('from-source');
219
+ expect(destPage.value).toBe('5');
220
+ container.remove();
221
+ });
222
+ it('data-click-copy-source copies binding data from specified non-form element', async () => {
223
+ const container = document.createElement('div');
224
+ document.body.appendChild(container);
225
+ const source = document.createElement('div');
226
+ source.id = 'copy-source-binding-div';
227
+ source.setAttribute('data-bind', '{"keyword":"from-binding","page":"9"}');
228
+ const dest = document.createElement('div');
229
+ dest.id = 'copy-dest-binding-div';
230
+ dest.setAttribute('data-bind', '{"keyword":"old","keep":"yes"}');
231
+ const btn = document.createElement('button');
232
+ btn.setAttribute('data-click-copy-source', '#copy-source-binding-div');
233
+ btn.setAttribute('data-click-copy', '#copy-dest-binding-div');
234
+ container.append(source, dest, btn);
235
+ await waitForDomSettled();
236
+ btn.click();
237
+ await waitForCondition(() => {
238
+ const bind = dest.getAttribute('data-bind');
239
+ return bind !== null && bind.includes('"keyword":"from-binding"');
240
+ }, { description: 'copy from specified source binding data' });
241
+ const copied = JSON.parse(dest.getAttribute('data-bind') || '{}');
242
+ expect(copied).toMatchObject({ keyword: 'from-binding', page: '9', keep: 'yes' });
243
+ container.remove();
244
+ });
245
+ it('data-click-copy-source takes precedence over data-click-form', async () => {
246
+ const container = document.createElement('div');
247
+ document.body.appendChild(container);
248
+ const triggerForm = document.createElement('form');
249
+ triggerForm.id = 'copy-trigger-form';
250
+ const triggerInput = document.createElement('input');
251
+ triggerInput.name = 'keyword';
252
+ triggerInput.value = 'trigger-value';
253
+ triggerForm.appendChild(triggerInput);
254
+ const sourceForm = document.createElement('form');
255
+ sourceForm.id = 'copy-source-priority-form';
256
+ const sourceInput = document.createElement('input');
257
+ sourceInput.name = 'keyword';
258
+ sourceInput.value = 'source-value';
259
+ sourceForm.appendChild(sourceInput);
260
+ const dest = document.createElement('div');
261
+ dest.id = 'copy-dest-priority-div';
262
+ dest.setAttribute('data-bind', '{}');
263
+ const btn = document.createElement('button');
264
+ btn.setAttribute('data-click-form', '#copy-trigger-form');
265
+ btn.setAttribute('data-click-copy-source', '#copy-source-priority-form');
266
+ btn.setAttribute('data-click-copy', '#copy-dest-priority-div');
267
+ container.append(triggerForm, sourceForm, dest, btn);
268
+ await waitForDomSettled();
269
+ btn.click();
270
+ await waitForCondition(() => {
271
+ const bind = dest.getAttribute('data-bind');
272
+ return bind !== null && bind.includes('"keyword":"source-value"');
273
+ }, { description: 'copy-source takes precedence over form' });
274
+ const copied = JSON.parse(dest.getAttribute('data-bind') || '{}');
275
+ expect(copied).toMatchObject({ keyword: 'source-value' });
276
+ container.remove();
277
+ });
278
+ it('data-click-copy-source logs error when selector does not match', async () => {
279
+ const container = document.createElement('div');
280
+ document.body.appendChild(container);
281
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
282
+ const dest = document.createElement('div');
283
+ dest.id = 'copy-dest-missing-source';
284
+ dest.setAttribute('data-bind', '{"keep":"yes"}');
285
+ const btn = document.createElement('button');
286
+ btn.setAttribute('data-bind', '{"keyword":"trigger"}');
287
+ btn.setAttribute('data-click-copy-source', '#no-such-source');
288
+ btn.setAttribute('data-click-copy', '#copy-dest-missing-source');
289
+ container.append(dest, btn);
290
+ await waitForDomSettled();
291
+ btn.click();
292
+ await waitForCondition(() => errorSpy.mock.calls.some(call => call.some(arg => typeof arg === 'string' &&
293
+ arg.includes('Element not found: #no-such-source'))), { description: 'missing copy-source error' });
294
+ const copied = JSON.parse(dest.getAttribute('data-bind') || '{}');
295
+ expect(copied).toMatchObject({ keep: 'yes' });
296
+ errorSpy.mockRestore();
297
+ container.remove();
298
+ });
299
+ it('data-click-copy-source="" (empty) uses the trigger element itself as source', async () => {
300
+ const container = document.createElement('div');
301
+ document.body.appendChild(container);
302
+ const btn = document.createElement('button');
303
+ btn.setAttribute('data-bind', '{"keyword":"self-source","page":"1"}');
304
+ btn.setAttribute('data-click-copy-source', '');
305
+ btn.setAttribute('data-click-copy', '#copy-dest-self-source');
306
+ const dest = document.createElement('div');
307
+ dest.id = 'copy-dest-self-source';
308
+ dest.setAttribute('data-bind', '{"keep":"yes"}');
309
+ container.append(btn, dest);
310
+ await waitForDomSettled();
311
+ btn.click();
312
+ await waitForCondition(() => {
313
+ const bind = dest.getAttribute('data-bind');
314
+ return bind !== null && bind.includes('"keyword":"self-source"');
315
+ }, { description: 'copy from self (empty selector)' });
316
+ const copied = JSON.parse(dest.getAttribute('data-bind') || '{}');
317
+ expect(copied).toMatchObject({ keyword: 'self-source', page: '1', keep: 'yes' });
318
+ container.remove();
319
+ });
192
320
  it('data-click-copy-params copies only selected keys and preserves target data', async () => {
193
321
  const container = document.createElement('div');
194
322
  document.body.appendChild(container);