@hyperfixi/behaviors 2.5.1 → 2.7.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 (182) hide show
  1. package/README.md +100 -0
  2. package/dist/all.browser.global.js +43 -40
  3. package/dist/all.browser.global.js.map +1 -1
  4. package/dist/autodismiss.browser.global.js +2 -2
  5. package/dist/autodismiss.browser.global.js.map +1 -1
  6. package/dist/behaviors/autodismiss.cjs +5 -54
  7. package/dist/behaviors/autodismiss.cjs.map +1 -1
  8. package/dist/behaviors/autodismiss.d.cts +3 -2
  9. package/dist/behaviors/autodismiss.d.ts +3 -2
  10. package/dist/behaviors/autodismiss.js +1 -1
  11. package/dist/behaviors/clickoutside.cjs +6 -28
  12. package/dist/behaviors/clickoutside.cjs.map +1 -1
  13. package/dist/behaviors/clickoutside.d.cts +3 -2
  14. package/dist/behaviors/clickoutside.d.ts +3 -2
  15. package/dist/behaviors/clickoutside.js +2 -2
  16. package/dist/behaviors/clipboard.cjs +30 -86
  17. package/dist/behaviors/clipboard.cjs.map +1 -1
  18. package/dist/behaviors/clipboard.d.cts +3 -2
  19. package/dist/behaviors/clipboard.d.ts +3 -2
  20. package/dist/behaviors/clipboard.js +2 -2
  21. package/dist/behaviors/draggable.cjs +6 -41
  22. package/dist/behaviors/draggable.cjs.map +1 -1
  23. package/dist/behaviors/draggable.d.cts +3 -3
  24. package/dist/behaviors/draggable.d.ts +3 -3
  25. package/dist/behaviors/draggable.js +2 -2
  26. package/dist/behaviors/focustrap.cjs +5 -81
  27. package/dist/behaviors/focustrap.cjs.map +1 -1
  28. package/dist/behaviors/focustrap.d.cts +3 -2
  29. package/dist/behaviors/focustrap.d.ts +3 -2
  30. package/dist/behaviors/focustrap.js +1 -1
  31. package/dist/behaviors/removable.d.cts +1 -1
  32. package/dist/behaviors/removable.d.ts +1 -1
  33. package/dist/behaviors/resizable.cjs +9 -49
  34. package/dist/behaviors/resizable.cjs.map +1 -1
  35. package/dist/behaviors/resizable.d.cts +3 -2
  36. package/dist/behaviors/resizable.d.ts +3 -2
  37. package/dist/behaviors/resizable.js +2 -2
  38. package/dist/behaviors/scrollreveal.cjs +5 -32
  39. package/dist/behaviors/scrollreveal.cjs.map +1 -1
  40. package/dist/behaviors/scrollreveal.d.cts +3 -2
  41. package/dist/behaviors/scrollreveal.d.ts +3 -2
  42. package/dist/behaviors/scrollreveal.js +1 -1
  43. package/dist/behaviors/sortable.cjs +8 -43
  44. package/dist/behaviors/sortable.cjs.map +1 -1
  45. package/dist/behaviors/sortable.d.cts +3 -2
  46. package/dist/behaviors/sortable.d.ts +3 -2
  47. package/dist/behaviors/sortable.js +2 -2
  48. package/dist/behaviors/tabs.cjs +5 -120
  49. package/dist/behaviors/tabs.cjs.map +1 -1
  50. package/dist/behaviors/tabs.d.cts +3 -2
  51. package/dist/behaviors/tabs.d.ts +3 -2
  52. package/dist/behaviors/tabs.js +1 -1
  53. package/dist/behaviors/toggleable.cjs +5 -27
  54. package/dist/behaviors/toggleable.cjs.map +1 -1
  55. package/dist/behaviors/toggleable.d.cts +3 -3
  56. package/dist/behaviors/toggleable.d.ts +3 -3
  57. package/dist/behaviors/toggleable.js +1 -1
  58. package/dist/{chunk-SLOXO6IY.js → chunk-26IX7QU7.js} +2 -2
  59. package/dist/{chunk-SLOXO6IY.js.map → chunk-26IX7QU7.js.map} +1 -1
  60. package/dist/chunk-2HUF6RMI.js +40 -0
  61. package/dist/chunk-2HUF6RMI.js.map +1 -0
  62. package/dist/{chunk-ZBSWTV6I.js → chunk-5LFOFVZ3.js} +5 -5
  63. package/dist/chunk-5LFOFVZ3.js.map +1 -0
  64. package/dist/{chunk-BBIIOGMF.js → chunk-6EG2MNC7.js} +6 -6
  65. package/dist/chunk-7NGOA3T5.js +40 -0
  66. package/dist/chunk-7NGOA3T5.js.map +1 -0
  67. package/dist/{chunk-ZZ2WSAX3.js → chunk-7TPDLB7W.js} +7 -29
  68. package/dist/chunk-7TPDLB7W.js.map +1 -0
  69. package/dist/chunk-CR3J7VV6.js +40 -0
  70. package/dist/chunk-CR3J7VV6.js.map +1 -0
  71. package/dist/chunk-EDBG2F57.js +40 -0
  72. package/dist/chunk-EDBG2F57.js.map +1 -0
  73. package/dist/{chunk-G6WR2AYR.js → chunk-FBQ3X3UY.js} +2 -2
  74. package/dist/chunk-FBQ3X3UY.js.map +1 -0
  75. package/dist/chunk-HX663BJ4.js +40 -0
  76. package/dist/chunk-HX663BJ4.js.map +1 -0
  77. package/dist/chunk-KROW43CG.js +40 -0
  78. package/dist/chunk-KROW43CG.js.map +1 -0
  79. package/dist/chunk-ORBJ7MRW.js +40 -0
  80. package/dist/chunk-ORBJ7MRW.js.map +1 -0
  81. package/dist/{chunk-V2M4W3LO.js → chunk-PDMOOWFW.js} +4 -4
  82. package/dist/chunk-PDMOOWFW.js.map +1 -0
  83. package/dist/chunk-SLSCZV4A.js +40 -0
  84. package/dist/chunk-SLSCZV4A.js.map +1 -0
  85. package/dist/chunk-WKBRZGGL.js +40 -0
  86. package/dist/chunk-WKBRZGGL.js.map +1 -0
  87. package/dist/{chunk-WOTZO7K4.js → chunk-YXS27XM6.js} +26 -23
  88. package/dist/chunk-YXS27XM6.js.map +1 -0
  89. package/dist/clickoutside.browser.global.js +3 -3
  90. package/dist/clickoutside.browser.global.js.map +1 -1
  91. package/dist/clipboard.browser.global.js +27 -24
  92. package/dist/clipboard.browser.global.js.map +1 -1
  93. package/dist/core.browser.global.js +3 -3
  94. package/dist/core.browser.global.js.map +1 -1
  95. package/dist/draggable.browser.global.js +2 -2
  96. package/dist/draggable.browser.global.js.map +1 -1
  97. package/dist/focustrap.browser.global.js +2 -2
  98. package/dist/focustrap.browser.global.js.map +1 -1
  99. package/dist/index.cjs +120 -563
  100. package/dist/index.cjs.map +1 -1
  101. package/dist/index.d.cts +41 -3
  102. package/dist/index.d.ts +41 -3
  103. package/dist/index.js +44 -16
  104. package/dist/index.js.map +1 -1
  105. package/dist/loaders.cjs +86 -563
  106. package/dist/loaders.cjs.map +1 -1
  107. package/dist/loaders.js +6 -6
  108. package/dist/registry.d.cts +1 -1
  109. package/dist/registry.d.ts +1 -1
  110. package/dist/resizable.browser.global.js +5 -5
  111. package/dist/resizable.browser.global.js.map +1 -1
  112. package/dist/resolver.browser.global.js +34 -31
  113. package/dist/resolver.browser.global.js.map +1 -1
  114. package/dist/schemas/index.cjs +34 -31
  115. package/dist/schemas/index.cjs.map +1 -1
  116. package/dist/schemas/index.d.cts +2 -2
  117. package/dist/schemas/index.d.ts +2 -2
  118. package/dist/schemas/index.js +5 -5
  119. package/dist/scrollreveal.browser.global.js +2 -2
  120. package/dist/scrollreveal.browser.global.js.map +1 -1
  121. package/dist/sortable.browser.global.js +4 -4
  122. package/dist/sortable.browser.global.js.map +1 -1
  123. package/dist/tabs.browser.global.js +2 -2
  124. package/dist/tabs.browser.global.js.map +1 -1
  125. package/dist/toggleable.browser.global.js +2 -2
  126. package/dist/toggleable.browser.global.js.map +1 -1
  127. package/dist/{types-BY-jB5FV.d.ts → types-DCCtVQS5.d.cts} +1 -1
  128. package/dist/{types-BY-jB5FV.d.cts → types-DCCtVQS5.d.ts} +1 -1
  129. package/package.json +4 -4
  130. package/src/behaviors/autodismiss.test.ts +15 -9
  131. package/src/behaviors/autodismiss.ts +15 -70
  132. package/src/behaviors/clickoutside.test.ts +17 -9
  133. package/src/behaviors/clickoutside.ts +13 -39
  134. package/src/behaviors/clipboard.test.ts +16 -10
  135. package/src/behaviors/clipboard.ts +15 -93
  136. package/src/behaviors/curated-runtime.test.ts +158 -0
  137. package/src/behaviors/draggable.ts +15 -59
  138. package/src/behaviors/experimental-runtime.test.ts +136 -0
  139. package/src/behaviors/focustrap.test.ts +15 -9
  140. package/src/behaviors/focustrap.ts +17 -121
  141. package/src/behaviors/integration.test.ts +9 -64
  142. package/src/behaviors/optional-runtime.test.ts +251 -0
  143. package/src/behaviors/resizable.ts +14 -61
  144. package/src/behaviors/scrollreveal.test.ts +17 -9
  145. package/src/behaviors/scrollreveal.ts +17 -41
  146. package/src/behaviors/sortable.ts +15 -58
  147. package/src/behaviors/tabs.test.ts +16 -10
  148. package/src/behaviors/tabs.ts +18 -160
  149. package/src/behaviors/toggleable.ts +14 -41
  150. package/src/curation.test.ts +45 -0
  151. package/src/curation.ts +58 -0
  152. package/src/index.ts +14 -0
  153. package/src/registry.test.ts +6 -11
  154. package/src/schemas/clickoutside.schema.ts +1 -1
  155. package/src/schemas/clipboard.schema.ts +25 -22
  156. package/src/schemas/draggable.schema.ts +2 -1
  157. package/src/schemas/resizable.schema.ts +5 -4
  158. package/src/schemas/sortable.schema.ts +4 -3
  159. package/dist/chunk-7O7UOSBH.js +0 -99
  160. package/dist/chunk-7O7UOSBH.js.map +0 -1
  161. package/dist/chunk-EKKJVXDZ.js +0 -67
  162. package/dist/chunk-EKKJVXDZ.js.map +0 -1
  163. package/dist/chunk-F3CASJ7Q.js +0 -116
  164. package/dist/chunk-F3CASJ7Q.js.map +0 -1
  165. package/dist/chunk-G6WR2AYR.js.map +0 -1
  166. package/dist/chunk-GALKOMJ5.js +0 -62
  167. package/dist/chunk-GALKOMJ5.js.map +0 -1
  168. package/dist/chunk-IMDFEGIT.js +0 -75
  169. package/dist/chunk-IMDFEGIT.js.map +0 -1
  170. package/dist/chunk-IT6TYVMW.js +0 -80
  171. package/dist/chunk-IT6TYVMW.js.map +0 -1
  172. package/dist/chunk-LQJOQTBZ.js +0 -89
  173. package/dist/chunk-LQJOQTBZ.js.map +0 -1
  174. package/dist/chunk-S4QDV2MH.js +0 -75
  175. package/dist/chunk-S4QDV2MH.js.map +0 -1
  176. package/dist/chunk-V2M4W3LO.js.map +0 -1
  177. package/dist/chunk-WOTZO7K4.js.map +0 -1
  178. package/dist/chunk-WPOQIMCC.js +0 -155
  179. package/dist/chunk-WPOQIMCC.js.map +0 -1
  180. package/dist/chunk-ZBSWTV6I.js.map +0 -1
  181. package/dist/chunk-ZZ2WSAX3.js.map +0 -1
  182. /package/dist/{chunk-BBIIOGMF.js.map → chunk-6EG2MNC7.js.map} +0 -0
@@ -0,0 +1,158 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * Curated behaviors — REAL runtime behavior tests (Phase 2).
4
+ *
5
+ * These exercise each curated behavior's actual hyperscript `source` through the
6
+ * real @hyperfixi/core runtime: register → install on an element → drive the DOM →
7
+ * assert both the effect AND the documented lifecycle events. This is the
8
+ * "parses ≠ works" guard (BEHAVIORS_CONSOLIDATION_PLAN.md §2/§5) — the mock-based
9
+ * unit tests only prove `register*()` calls compileSync; these prove the js()
10
+ * bodies actually do the right thing at runtime.
11
+ *
12
+ * These tests already paid for themselves: they surfaced two source bugs the
13
+ * imperative path was masking — ClickOutside read `event.target` without passing
14
+ * `event` into its js() block, and Clipboard used top-level `await` (invalid in a
15
+ * js() block). Both are fixed in the schema sources.
16
+ */
17
+ import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
18
+ import { hyperscript } from '@hyperfixi/core';
19
+ import { registerToggleable } from './toggleable';
20
+ import { registerRemovable } from './removable';
21
+ import { registerClickOutside } from './clickoutside';
22
+ import { registerClipboard } from './clipboard';
23
+ import { registerAutoDismiss } from './autodismiss';
24
+
25
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
+ const hf = hyperscript as any;
27
+
28
+ const tick = (ms = 30) => new Promise(r => setTimeout(r, ms));
29
+
30
+ async function install(code: string, el: HTMLElement): Promise<void> {
31
+ const r = hyperscript.compileSync(code, { traditional: true });
32
+ if (!r.ok) throw new Error(`install compile failed: ${JSON.stringify(r.errors)}`);
33
+ await hyperscript.execute(r.ast, hyperscript.createContext(el));
34
+ }
35
+
36
+ beforeAll(async () => {
37
+ // Behavior registry is a runtime singleton; register the curated set once.
38
+ await registerToggleable(hf);
39
+ await registerRemovable(hf);
40
+ await registerClickOutside(hf);
41
+ await registerClipboard(hf);
42
+ await registerAutoDismiss(hf);
43
+ });
44
+
45
+ afterEach(() => {
46
+ document.body.innerHTML = '';
47
+ });
48
+
49
+ describe('Toggleable — runtime', () => {
50
+ it('toggles the parameterized class and fires on/off lifecycle events', async () => {
51
+ const el = document.createElement('button');
52
+ document.body.appendChild(el);
53
+ const on = vi.fn();
54
+ const off = vi.fn();
55
+ el.addEventListener('toggleable:on', on);
56
+ el.addEventListener('toggleable:off', off);
57
+
58
+ await install('install Toggleable(cls: "hl")', el);
59
+
60
+ el.click();
61
+ await tick();
62
+ expect(el.classList.contains('hl')).toBe(true);
63
+ expect(on).toHaveBeenCalledTimes(1);
64
+
65
+ el.click();
66
+ await tick();
67
+ expect(el.classList.contains('hl')).toBe(false);
68
+ expect(off).toHaveBeenCalledTimes(1);
69
+ });
70
+ });
71
+
72
+ describe('Removable — runtime', () => {
73
+ it('removes the element on click and fires before/removed events', async () => {
74
+ const el = document.createElement('div');
75
+ el.id = 'rm-target';
76
+ document.body.appendChild(el);
77
+ const before = vi.fn();
78
+ const removed = vi.fn();
79
+ el.addEventListener('removable:before', before);
80
+ document.addEventListener('removable:removed', removed, { once: true });
81
+
82
+ await install('install Removable', el);
83
+
84
+ el.click();
85
+ await tick();
86
+
87
+ expect(document.getElementById('rm-target')).toBeNull();
88
+ expect(before).toHaveBeenCalledTimes(1);
89
+ expect(removed).toHaveBeenCalledTimes(1);
90
+ });
91
+ });
92
+
93
+ describe('ClickOutside — runtime', () => {
94
+ it('fires clickoutside for outside presses but not inside', async () => {
95
+ const el = document.createElement('div');
96
+ const outside = document.createElement('button');
97
+ document.body.append(el, outside);
98
+ const fired = vi.fn();
99
+ el.addEventListener('clickoutside', fired);
100
+
101
+ await install('install ClickOutside', el);
102
+
103
+ // The source passes `event` into its js() block, so dispatch sets event.target.
104
+ const press = (target: HTMLElement) =>
105
+ target.dispatchEvent(new Event('pointerdown', { bubbles: true }));
106
+
107
+ press(outside);
108
+ await tick();
109
+ expect(fired).toHaveBeenCalledTimes(1);
110
+
111
+ fired.mockClear();
112
+ press(el);
113
+ await tick();
114
+ expect(fired).not.toHaveBeenCalled();
115
+ });
116
+ });
117
+
118
+ describe('Clipboard — runtime', () => {
119
+ it('writes literal text, adds .copied feedback, and fires clipboard:copied', async () => {
120
+ const writeText = vi.fn().mockResolvedValue(undefined);
121
+ Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
122
+
123
+ const el = document.createElement('button');
124
+ document.body.appendChild(el);
125
+ const copied = vi.fn();
126
+ el.addEventListener('clipboard:copied', copied);
127
+
128
+ await install('install Clipboard(text: "hello")', el);
129
+
130
+ el.click();
131
+ await tick();
132
+
133
+ expect(writeText).toHaveBeenCalledWith('hello');
134
+ expect(el.classList.contains('copied')).toBe(true);
135
+ expect(copied).toHaveBeenCalledTimes(1);
136
+ });
137
+ });
138
+
139
+ describe('AutoDismiss — runtime', () => {
140
+ it('fires start, then removes the element after the delay and fires dismissed', async () => {
141
+ const el = document.createElement('div');
142
+ el.id = 'ad-target';
143
+ document.body.appendChild(el);
144
+ const start = vi.fn();
145
+ const dismissed = vi.fn();
146
+ el.addEventListener('autodismiss:start', start);
147
+ document.addEventListener('autodismiss:dismissed', dismissed, { once: true });
148
+
149
+ await install('install AutoDismiss(delay: 10)', el);
150
+
151
+ expect(start).toHaveBeenCalledTimes(1);
152
+
153
+ await tick(80);
154
+
155
+ expect(document.getElementById('ad-target')).toBeNull();
156
+ expect(dismissed).toHaveBeenCalledTimes(1);
157
+ });
158
+ });
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Draggable Behavior — Imperative Implementation
2
+ * Draggable Behavior
3
3
  *
4
- * Makes elements draggable with pointer events.
5
- * Uses direct DOM APIs instead of compiling hyperscript source,
6
- * bypassing the complex repeat-until-event + wait-for async pattern.
4
+ * Makes elements draggable with pointer events. Compiled from its hyperscript
5
+ * `source` (the single source of truth shared with the CDN resolver bundle and
6
+ * patterns-reference) — no imperative installer. The source uses
7
+ * `repeat until event pointerup` + `wait for pointermove or pointerup` to run the
8
+ * drag loop, `measure` for the start offset, and `add { left: ${…}px }` to move.
7
9
  *
8
10
  * @example
9
11
  * ```html
@@ -27,51 +29,8 @@ export const draggableSource = draggableSchema.source;
27
29
  export const draggableMetadata = draggableSchema;
28
30
 
29
31
  /**
30
- * Imperative installer for Draggable behavior.
31
- * Called directly by the runtime when `install Draggable` is executed.
32
- */
33
- function installDraggable(element: HTMLElement, params: Record<string, any>): void {
34
- // Resolve drag handle: parameter value, CSS selector, or the element itself
35
- let dragHandle: HTMLElement = element;
36
- if (params.dragHandle && params.dragHandle !== 'me') {
37
- if (typeof params.dragHandle === 'string') {
38
- const found = element.querySelector(params.dragHandle);
39
- if (found instanceof HTMLElement) dragHandle = found;
40
- } else if (params.dragHandle instanceof HTMLElement) {
41
- dragHandle = params.dragHandle;
42
- }
43
- }
44
-
45
- dragHandle.addEventListener('pointerdown', (e: PointerEvent) => {
46
- e.preventDefault();
47
- e.stopPropagation();
48
- element.dispatchEvent(new CustomEvent('draggable:start', { bubbles: true }));
49
-
50
- const startX = element.offsetLeft;
51
- const startY = element.offsetTop;
52
- const xoff = e.clientX - startX;
53
- const yoff = e.clientY - startY;
54
-
55
- function onMove(ev: PointerEvent) {
56
- element.style.left = `${ev.clientX - xoff}px`;
57
- element.style.top = `${ev.clientY - yoff}px`;
58
- element.dispatchEvent(new CustomEvent('draggable:move', { bubbles: true }));
59
- }
60
-
61
- function onUp() {
62
- document.removeEventListener('pointermove', onMove);
63
- document.removeEventListener('pointerup', onUp);
64
- element.dispatchEvent(new CustomEvent('draggable:end', { bubbles: true }));
65
- }
66
-
67
- document.addEventListener('pointermove', onMove);
68
- document.addEventListener('pointerup', onUp);
69
- });
70
- }
71
-
72
- /**
73
- * Register the Draggable behavior with LokaScript.
74
- * Uses imperative installer via synthetic behavior node.
32
+ * Register the Draggable behavior with LokaScript by compiling its hyperscript
33
+ * source and executing the resulting behavior definition.
75
34
  */
76
35
  export async function registerDraggable(hyperfixi?: LokaScriptInstance): Promise<void> {
77
36
  const hf = hyperfixi || resolveRuntime();
@@ -82,17 +41,14 @@ export async function registerDraggable(hyperfixi?: LokaScriptInstance): Promise
82
41
  );
83
42
  }
84
43
 
85
- // Register via execute() with a synthetic behavior node containing the imperative installer.
86
- // The runtime detects imperativeInstaller and stores it directly in the behaviorRegistry.
87
- const syntheticNode = {
88
- type: 'behavior',
89
- name: 'Draggable',
90
- parameters: ['dragHandle'],
91
- eventHandlers: [],
92
- imperativeInstaller: installDraggable,
93
- };
44
+ const result = hf.compileSync(draggableSchema.source, { traditional: true });
45
+
46
+ if (!result.ok) {
47
+ throw new Error(`Failed to compile Draggable behavior: ${JSON.stringify(result.errors)}`);
48
+ }
49
+
94
50
  const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
95
- await hf.execute(syntheticNode, ctx);
51
+ await hf.execute(result.ast, ctx);
96
52
  }
97
53
 
98
54
  // Auto-register when loaded as a script tag
@@ -0,0 +1,136 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * Experimental behaviors — REAL runtime tests.
4
+ *
5
+ * Draggable / Sortable / Resizable used to run imperative JS installers. They now
6
+ * compile from their hyperscript `source` like every other behavior (the
7
+ * "no imperative JS" rule — no behavior may carry an imperative installer field).
8
+ * These tests are the "parses ≠ works" guard: register → install → drive pointer
9
+ * events → assert the DOM effect AND the documented lifecycle events.
10
+ *
11
+ * happy-dom has no PointerEvent and zero layout, so we drive `MouseEvent`s (which
12
+ * carry clientX/clientY) and assert deltas, which are layout-independent.
13
+ */
14
+ import { describe, it, expect, beforeAll, afterEach } from 'vitest';
15
+ import { hyperscript } from '@hyperfixi/core';
16
+ import { registerDraggable } from './draggable';
17
+ import { registerSortable } from './sortable';
18
+ import { registerResizable } from './resizable';
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
21
+ const hf = hyperscript as any;
22
+ const tick = (ms = 30) => new Promise(r => setTimeout(r, ms));
23
+
24
+ function pointer(type: string, x: number, y: number): MouseEvent {
25
+ return new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true });
26
+ }
27
+
28
+ async function install(code: string, el: HTMLElement): Promise<void> {
29
+ const r = hyperscript.compileSync(code, { traditional: true });
30
+ if (!r.ok) throw new Error(`install compile failed: ${JSON.stringify(r.errors)}`);
31
+ await hyperscript.execute(r.ast, hyperscript.createContext(el));
32
+ }
33
+
34
+ beforeAll(async () => {
35
+ await registerDraggable(hf);
36
+ await registerSortable(hf);
37
+ await registerResizable(hf);
38
+ });
39
+
40
+ afterEach(() => {
41
+ document.body.innerHTML = '';
42
+ });
43
+
44
+ describe('Draggable — runtime (source-compiled)', () => {
45
+ it('moves the element by the pointer delta and fires start/move/end', async () => {
46
+ const el = document.createElement('div');
47
+ el.style.position = 'absolute';
48
+ el.style.left = '0px';
49
+ el.style.top = '0px';
50
+ document.body.appendChild(el);
51
+
52
+ const ev: string[] = [];
53
+ el.addEventListener('draggable:start', () => ev.push('start'));
54
+ el.addEventListener('draggable:move', () => ev.push('move'));
55
+ el.addEventListener('draggable:end', () => ev.push('end'));
56
+
57
+ await install('install Draggable', el);
58
+
59
+ el.dispatchEvent(pointer('pointerdown', 10, 10));
60
+ await tick();
61
+ document.dispatchEvent(pointer('pointermove', 60, 80));
62
+ await tick();
63
+ document.dispatchEvent(pointer('pointerup', 60, 80));
64
+ await tick(40);
65
+
66
+ // measure x/y = 0 in happy-dom → xoff = 10, so left = 60 - 10 = 50, top = 80 - 10 = 70
67
+ expect(el.style.left).toBe('50px');
68
+ expect(el.style.top).toBe('70px');
69
+ expect(ev).toContain('start');
70
+ expect(ev).toContain('move');
71
+ expect(ev).toContain('end');
72
+ });
73
+ });
74
+
75
+ describe('Sortable — runtime (source-compiled)', () => {
76
+ it('toggles the drag class on the <li> and fires start/move/end', async () => {
77
+ const ul = document.createElement('ul');
78
+ ul.innerHTML = '<li id="a">A</li><li id="b">B</li>';
79
+ document.body.appendChild(ul);
80
+
81
+ const ev: string[] = [];
82
+ ul.addEventListener('sortable:start', () => ev.push('start'));
83
+ ul.addEventListener('sortable:move', () => ev.push('move'));
84
+ ul.addEventListener('sortable:end', () => ev.push('end'));
85
+
86
+ await install('install Sortable', ul);
87
+
88
+ const li = ul.querySelector('#a') as HTMLElement;
89
+ li.dispatchEvent(pointer('pointerdown', 0, 5));
90
+ await tick();
91
+ const classDuringDrag = li.classList.contains('sorting');
92
+ document.dispatchEvent(pointer('pointermove', 0, 40));
93
+ await tick();
94
+ document.dispatchEvent(pointer('pointerup', 0, 40));
95
+ await tick(40);
96
+
97
+ expect(classDuringDrag).toBe(true); // dragClass added on pointerdown
98
+ expect(li.classList.contains('sorting')).toBe(false); // removed on pointerup
99
+ expect(ev).toContain('start');
100
+ expect(ev).toContain('move');
101
+ expect(ev).toContain('end');
102
+ });
103
+ });
104
+
105
+ describe('Resizable — runtime (source-compiled)', () => {
106
+ it('resizes by the pointer delta and fires start/resize/end', async () => {
107
+ const box = document.createElement('div');
108
+ box.style.width = '100px';
109
+ box.style.height = '100px';
110
+ document.body.appendChild(box);
111
+
112
+ const ev: string[] = [];
113
+ box.addEventListener('resizable:start', () => ev.push('start'));
114
+ box.addEventListener('resizable:resize', () => ev.push('resize'));
115
+ box.addEventListener('resizable:end', () => ev.push('end'));
116
+
117
+ await install(
118
+ 'install Resizable(minWidth: 10, minHeight: 10, maxWidth: 999, maxHeight: 999)',
119
+ box
120
+ );
121
+
122
+ box.dispatchEvent(pointer('pointerdown', 10, 10));
123
+ await tick();
124
+ document.dispatchEvent(pointer('pointermove', 60, 40));
125
+ await tick();
126
+ document.dispatchEvent(pointer('pointerup', 60, 40));
127
+ await tick(40);
128
+
129
+ // measure width/height = 0 in happy-dom → newWidth = 0 + 60 - 10 = 50, newHeight = 0 + 40 - 10 = 30
130
+ expect(box.style.width).toBe('50px');
131
+ expect(box.style.height).toBe('30px');
132
+ expect(ev).toContain('start');
133
+ expect(ev).toContain('resize');
134
+ expect(ev).toContain('end');
135
+ });
136
+ });
@@ -2,27 +2,33 @@ import { describe, it, expect, vi } from 'vitest';
2
2
 
3
3
  describe('FocusTrap behavior', () => {
4
4
  describe('registerFocusTrap', () => {
5
- it('should register with synthetic node via execute', async () => {
6
- const { registerFocusTrap } = await import('./focustrap');
5
+ it('should compile its hyperscript source and execute', async () => {
6
+ const { registerFocusTrap, focusTrapSource } = await import('./focustrap');
7
7
  const mock = {
8
- compileSync: vi.fn(),
8
+ compileSync: vi.fn().mockReturnValue({ ok: true, ast: { type: 'behavior' } }),
9
9
  execute: vi.fn().mockResolvedValue(undefined),
10
10
  createContext: vi.fn().mockReturnValue({ locals: new Map(), globals: new Map() }),
11
11
  };
12
12
 
13
13
  await registerFocusTrap(mock);
14
14
 
15
- expect(mock.compileSync).not.toHaveBeenCalled();
15
+ expect(mock.compileSync).toHaveBeenCalledWith(focusTrapSource, { traditional: true });
16
16
  expect(mock.execute).toHaveBeenCalledWith(
17
- expect.objectContaining({
18
- type: 'behavior',
19
- name: 'FocusTrap',
20
- imperativeInstaller: expect.any(Function),
21
- }),
17
+ { type: 'behavior' },
22
18
  expect.objectContaining({ locals: expect.any(Map), globals: expect.any(Map) })
23
19
  );
24
20
  });
25
21
 
22
+ it('should throw on compile failure', async () => {
23
+ const { registerFocusTrap } = await import('./focustrap');
24
+ const mock = {
25
+ compileSync: vi.fn().mockReturnValue({ ok: false, errors: [{ message: 'boom' }] }),
26
+ execute: vi.fn(),
27
+ createContext: vi.fn().mockReturnValue({ locals: new Map(), globals: new Map() }),
28
+ };
29
+ await expect(registerFocusTrap(mock)).rejects.toThrowError(/Failed to compile FocusTrap/);
30
+ });
31
+
26
32
  it('should throw when no runtime available', async () => {
27
33
  const { registerFocusTrap } = await import('./focustrap');
28
34
  await expect(registerFocusTrap(undefined)).rejects.toThrowError(/LokaScript not found/);
@@ -1,8 +1,14 @@
1
1
  /**
2
- * FocusTrap Behavior — Imperative Implementation
2
+ * FocusTrap Behavior
3
3
  *
4
4
  * Confines Tab navigation inside an element, manages aria-modal,
5
- * and restores focus on deactivation.
5
+ * and restores focus on deactivation. The Tab-trapping / focus-model logic
6
+ * lives in the schema's hyperscript `source` (an `init`-block `js()` body).
7
+ *
8
+ * Compiled from its hyperscript `source` — the single source of truth shared with
9
+ * the CDN resolver bundle and patterns-reference. This collapses FocusTrap onto
10
+ * the one runtime path (no more imperative-installer fork between CDN and npm);
11
+ * see docs-internal/BEHAVIORS_CONSOLIDATION_PLAN.md §3d.
6
12
  */
7
13
 
8
14
  import { focusTrapSchema } from '../schemas/focustrap.schema';
@@ -13,118 +19,9 @@ import { resolveRuntime } from '../schemas/types';
13
19
  export const focusTrapSource = focusTrapSchema.source;
14
20
  export const focusTrapMetadata = focusTrapSchema;
15
21
 
16
- const FOCUSABLE_SELECTOR = [
17
- 'a[href]',
18
- 'button:not(:disabled)',
19
- 'input:not(:disabled)',
20
- 'select:not(:disabled)',
21
- 'textarea:not(:disabled)',
22
- '[tabindex]:not([tabindex="-1"])',
23
- ].join(', ');
24
-
25
- /**
26
- * Resolve a selector parameter to an HTMLElement within the container.
27
- */
28
- function resolveElement(container: HTMLElement, param: unknown): HTMLElement | null {
29
- if (!param) return null;
30
- if (param instanceof HTMLElement) return param;
31
- if (typeof param === 'string') {
32
- return (
33
- (container.querySelector(param) as HTMLElement) ||
34
- (document.querySelector(param) as HTMLElement) ||
35
- null
36
- );
37
- }
38
- return null;
39
- }
40
-
41
22
  /**
42
- * Imperative installer for FocusTrap behavior.
43
- */
44
- function installFocusTrap(element: HTMLElement, params: Record<string, any>): void {
45
- const returnFocus = params.returnFocus !== false;
46
- let isActive = false;
47
- let previouslyFocused: Element | null = null;
48
-
49
- function getFocusableElements(): HTMLElement[] {
50
- return Array.from(element.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
51
- }
52
-
53
- function onKeyDown(e: KeyboardEvent) {
54
- if (!isActive || e.key !== 'Tab') return;
55
-
56
- const focusable = getFocusableElements();
57
- if (focusable.length === 0) {
58
- e.preventDefault();
59
- return;
60
- }
61
-
62
- // Always prevent default Tab and manually manage focus.
63
- // This ensures focus never escapes to elements outside the trap,
64
- // even when the modal is embedded in a page with other focusable elements.
65
- e.preventDefault();
66
-
67
- const currentIndex = focusable.indexOf(document.activeElement as HTMLElement);
68
-
69
- if (e.shiftKey) {
70
- const prevIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1;
71
- focusable[prevIndex].focus();
72
- } else {
73
- const nextIndex = currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1;
74
- focusable[nextIndex].focus();
75
- }
76
- }
77
-
78
- function activate() {
79
- if (isActive) return;
80
- isActive = true;
81
- previouslyFocused = document.activeElement;
82
-
83
- element.setAttribute('aria-modal', 'true');
84
-
85
- // Focus initialFocus element or first focusable
86
- const initialEl = resolveElement(element, params.initialFocus);
87
- if (initialEl) {
88
- initialEl.focus();
89
- } else {
90
- const focusable = getFocusableElements();
91
- if (focusable.length > 0) {
92
- focusable[0].focus();
93
- }
94
- }
95
-
96
- element.dispatchEvent(new CustomEvent('focustrap:activated', { bubbles: true }));
97
- }
98
-
99
- function deactivate() {
100
- if (!isActive) return;
101
- isActive = false;
102
-
103
- element.removeAttribute('aria-modal');
104
-
105
- if (returnFocus && previouslyFocused instanceof HTMLElement) {
106
- previouslyFocused.focus();
107
- }
108
- previouslyFocused = null;
109
-
110
- element.dispatchEvent(new CustomEvent('focustrap:deactivated', { bubbles: true }));
111
- }
112
-
113
- // Keydown handler for Tab trapping
114
- element.addEventListener('keydown', onKeyDown);
115
-
116
- // Programmatic activation/deactivation via custom events
117
- element.addEventListener('focustrap:activate', () => activate());
118
- element.addEventListener('focustrap:deactivate', () => deactivate());
119
-
120
- // Activate immediately if requested
121
- if (params.active !== false) {
122
- activate();
123
- }
124
- }
125
-
126
- /**
127
- * Register the FocusTrap behavior with LokaScript.
23
+ * Register the FocusTrap behavior with LokaScript by compiling its
24
+ * hyperscript source and executing the resulting behavior definition.
128
25
  */
129
26
  export async function registerFocusTrap(hyperfixi?: LokaScriptInstance): Promise<void> {
130
27
  const hf = hyperfixi || resolveRuntime();
@@ -135,15 +32,14 @@ export async function registerFocusTrap(hyperfixi?: LokaScriptInstance): Promise
135
32
  );
136
33
  }
137
34
 
138
- const syntheticNode = {
139
- type: 'behavior',
140
- name: 'FocusTrap',
141
- parameters: ['active', 'initialFocus', 'returnFocus'],
142
- eventHandlers: [],
143
- imperativeInstaller: installFocusTrap,
144
- };
35
+ const result = hf.compileSync(focusTrapSchema.source, { traditional: true });
36
+
37
+ if (!result.ok) {
38
+ throw new Error(`Failed to compile FocusTrap behavior: ${JSON.stringify(result.errors)}`);
39
+ }
40
+
145
41
  const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
146
- await hf.execute(syntheticNode, ctx);
42
+ await hf.execute(result.ast, ctx);
147
43
  }
148
44
 
149
45
  // Auto-register when loaded as a script tag
@@ -21,23 +21,24 @@ function createMockInstance(overrides: Partial<LokaScriptInstance> = {}): LokaSc
21
21
  };
22
22
  }
23
23
 
24
- // Source-compiled behaviors (use compileSync + execute)
24
+ // Source-compiled behaviors (use compileSync + execute).
25
+ // EVERY behavior compiles its hyperscript `source` — one runtime path, identical
26
+ // browser/npm, no imperative JS installer (the "no imperative JS" rule). The optional
27
+ // three (FocusTrap/ScrollReveal/Tabs) carry their web-API logic in an `init`-block
28
+ // `js()` body, and the experimental three (Draggable/Sortable/Resizable) run their
29
+ // pointer-drag loops via `repeat until event` — all through the single compile path.
25
30
  const compiledBehaviors = [
26
31
  { name: 'Removable', register: registerRemovable, source: removableSource },
27
- ] as const;
28
-
29
- // Imperative behaviors (use synthetic node with imperativeInstaller)
30
- const imperativeBehaviors = [
31
- { name: 'Draggable', register: registerDraggable, source: draggableSource },
32
32
  { name: 'Toggleable', register: registerToggleable, source: toggleableSource },
33
- { name: 'Sortable', register: registerSortable, source: sortableSource },
34
- { name: 'Resizable', register: registerResizable, source: resizableSource },
35
33
  { name: 'Clipboard', register: registerClipboard, source: clipboardSource },
36
34
  { name: 'AutoDismiss', register: registerAutoDismiss, source: autoDismissSource },
37
35
  { name: 'ClickOutside', register: registerClickOutside, source: clickOutsideSource },
38
36
  { name: 'FocusTrap', register: registerFocusTrap, source: focusTrapSource },
39
37
  { name: 'ScrollReveal', register: registerScrollReveal, source: scrollRevealSource },
40
38
  { name: 'Tabs', register: registerTabs, source: tabsSource },
39
+ { name: 'Draggable', register: registerDraggable, source: draggableSource },
40
+ { name: 'Sortable', register: registerSortable, source: sortableSource },
41
+ { name: 'Resizable', register: registerResizable, source: resizableSource },
41
42
  ] as const;
42
43
 
43
44
  describe('behavior registration integration', () => {
@@ -96,60 +97,4 @@ describe('behavior registration integration', () => {
96
97
  });
97
98
  }
98
99
  });
99
-
100
- describe('imperative behaviors', () => {
101
- for (const { name, register } of imperativeBehaviors) {
102
- describe(name, () => {
103
- it('should register via synthetic node with imperativeInstaller', async () => {
104
- const mock = createMockInstance();
105
- await register(mock);
106
-
107
- // Imperative behaviors do NOT call compileSync
108
- expect(mock.compileSync).not.toHaveBeenCalled();
109
-
110
- // They call execute with a synthetic node containing imperativeInstaller
111
- expect(mock.execute).toHaveBeenCalledWith(
112
- expect.objectContaining({
113
- type: 'behavior',
114
- name,
115
- imperativeInstaller: expect.any(Function),
116
- }),
117
- expect.objectContaining({
118
- locals: expect.any(Map),
119
- globals: expect.any(Map),
120
- })
121
- );
122
- });
123
-
124
- it('should create context', async () => {
125
- const mock = createMockInstance();
126
- await register(mock);
127
- expect(mock.createContext).toHaveBeenCalled();
128
- });
129
-
130
- it('should throw when lokascript is not available', async () => {
131
- await expect(register(undefined)).rejects.toThrowError(/LokaScript not found/);
132
- });
133
-
134
- it('should fall back to manual context when createContext is missing', async () => {
135
- const mock = createMockInstance();
136
- delete (mock as Partial<LokaScriptInstance>).createContext;
137
-
138
- await register(mock);
139
-
140
- expect(mock.execute).toHaveBeenCalledWith(
141
- expect.objectContaining({
142
- type: 'behavior',
143
- name,
144
- imperativeInstaller: expect.any(Function),
145
- }),
146
- expect.objectContaining({
147
- locals: expect.any(Map),
148
- globals: expect.any(Map),
149
- })
150
- );
151
- });
152
- });
153
- }
154
- });
155
100
  });