@hyperfixi/behaviors 2.0.0 → 2.1.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 (189) hide show
  1. package/dist/all.browser.global.js +35 -6
  2. package/dist/all.browser.global.js.map +1 -1
  3. package/dist/autodismiss.browser.global.js +3 -0
  4. package/dist/autodismiss.browser.global.js.map +1 -0
  5. package/dist/behaviors/autodismiss.cjs +177 -0
  6. package/dist/behaviors/autodismiss.cjs.map +1 -0
  7. package/dist/behaviors/autodismiss.d.cts +15 -0
  8. package/dist/behaviors/autodismiss.d.ts +15 -0
  9. package/dist/behaviors/autodismiss.js +15 -0
  10. package/dist/behaviors/autodismiss.js.map +1 -0
  11. package/dist/behaviors/clickoutside.cjs +123 -0
  12. package/dist/behaviors/clickoutside.cjs.map +1 -0
  13. package/dist/behaviors/clickoutside.d.cts +15 -0
  14. package/dist/behaviors/clickoutside.d.ts +15 -0
  15. package/dist/behaviors/clickoutside.js +15 -0
  16. package/dist/behaviors/clickoutside.js.map +1 -0
  17. package/dist/behaviors/clipboard.cjs +193 -0
  18. package/dist/behaviors/clipboard.cjs.map +1 -0
  19. package/dist/behaviors/clipboard.d.cts +15 -0
  20. package/dist/behaviors/clipboard.d.ts +15 -0
  21. package/dist/behaviors/clipboard.js +15 -0
  22. package/dist/behaviors/clipboard.js.map +1 -0
  23. package/dist/behaviors/draggable.cjs +49 -7
  24. package/dist/behaviors/draggable.cjs.map +1 -1
  25. package/dist/behaviors/draggable.d.cts +2 -1
  26. package/dist/behaviors/draggable.d.ts +2 -1
  27. package/dist/behaviors/draggable.js +2 -1
  28. package/dist/behaviors/focustrap.cjs +191 -0
  29. package/dist/behaviors/focustrap.cjs.map +1 -0
  30. package/dist/behaviors/focustrap.d.cts +15 -0
  31. package/dist/behaviors/focustrap.d.ts +15 -0
  32. package/dist/behaviors/focustrap.js +15 -0
  33. package/dist/behaviors/focustrap.js.map +1 -0
  34. package/dist/behaviors/removable.cjs +9 -2
  35. package/dist/behaviors/removable.cjs.map +1 -1
  36. package/dist/behaviors/removable.d.cts +1 -1
  37. package/dist/behaviors/removable.d.ts +1 -1
  38. package/dist/behaviors/removable.js +2 -1
  39. package/dist/behaviors/resizable.cjs +54 -7
  40. package/dist/behaviors/resizable.cjs.map +1 -1
  41. package/dist/behaviors/resizable.d.cts +1 -1
  42. package/dist/behaviors/resizable.d.ts +1 -1
  43. package/dist/behaviors/resizable.js +2 -1
  44. package/dist/behaviors/scrollreveal.cjs +149 -0
  45. package/dist/behaviors/scrollreveal.cjs.map +1 -0
  46. package/dist/behaviors/scrollreveal.d.cts +15 -0
  47. package/dist/behaviors/scrollreveal.d.ts +15 -0
  48. package/dist/behaviors/scrollreveal.js +15 -0
  49. package/dist/behaviors/scrollreveal.js.map +1 -0
  50. package/dist/behaviors/sortable.cjs +49 -7
  51. package/dist/behaviors/sortable.cjs.map +1 -1
  52. package/dist/behaviors/sortable.d.cts +1 -1
  53. package/dist/behaviors/sortable.d.ts +1 -1
  54. package/dist/behaviors/sortable.js +2 -1
  55. package/dist/behaviors/tabs.cjs +247 -0
  56. package/dist/behaviors/tabs.cjs.map +1 -0
  57. package/dist/behaviors/tabs.d.cts +15 -0
  58. package/dist/behaviors/tabs.d.ts +15 -0
  59. package/dist/behaviors/tabs.js +15 -0
  60. package/dist/behaviors/tabs.js.map +1 -0
  61. package/dist/behaviors/toggleable.cjs +36 -7
  62. package/dist/behaviors/toggleable.cjs.map +1 -1
  63. package/dist/behaviors/toggleable.d.cts +2 -1
  64. package/dist/behaviors/toggleable.d.ts +2 -1
  65. package/dist/behaviors/toggleable.js +2 -1
  66. package/dist/chunk-244WSZOL.js +67 -0
  67. package/dist/chunk-244WSZOL.js.map +1 -0
  68. package/dist/chunk-2NSKQMNM.js +99 -0
  69. package/dist/chunk-2NSKQMNM.js.map +1 -0
  70. package/dist/chunk-64DIQGQI.js +37 -0
  71. package/dist/chunk-64DIQGQI.js.map +1 -0
  72. package/dist/chunk-6TJXDCAQ.js +11 -0
  73. package/dist/chunk-6TJXDCAQ.js.map +1 -0
  74. package/dist/chunk-7JDV2THA.js +62 -0
  75. package/dist/chunk-7JDV2THA.js.map +1 -0
  76. package/dist/chunk-AFGA54SA.js +116 -0
  77. package/dist/chunk-AFGA54SA.js.map +1 -0
  78. package/dist/chunk-DYHBKDKB.js +62 -0
  79. package/dist/chunk-DYHBKDKB.js.map +1 -0
  80. package/dist/chunk-GPTHAUWN.js +58 -0
  81. package/dist/chunk-GPTHAUWN.js.map +1 -0
  82. package/dist/chunk-GVJPA2EK.js +68 -0
  83. package/dist/chunk-GVJPA2EK.js.map +1 -0
  84. package/dist/chunk-H2MCHR5A.js +89 -0
  85. package/dist/chunk-H2MCHR5A.js.map +1 -0
  86. package/dist/{chunk-2NQHIHD5.js → chunk-H5Y5ARYG.js} +6 -3
  87. package/dist/chunk-H5Y5ARYG.js.map +1 -0
  88. package/dist/chunk-JKBWIJFI.js +155 -0
  89. package/dist/chunk-JKBWIJFI.js.map +1 -0
  90. package/dist/chunk-QKDYW4VM.js +70 -0
  91. package/dist/chunk-QKDYW4VM.js.map +1 -0
  92. package/dist/chunk-RB7OB6OD.js +51 -0
  93. package/dist/chunk-RB7OB6OD.js.map +1 -0
  94. package/dist/chunk-RPERQTFU.js +75 -0
  95. package/dist/chunk-RPERQTFU.js.map +1 -0
  96. package/dist/chunk-S4QDV2MH.js +75 -0
  97. package/dist/chunk-S4QDV2MH.js.map +1 -0
  98. package/dist/chunk-WTUVPVSD.js +80 -0
  99. package/dist/chunk-WTUVPVSD.js.map +1 -0
  100. package/dist/chunk-YHQRFUWV.js +62 -0
  101. package/dist/chunk-YHQRFUWV.js.map +1 -0
  102. package/dist/chunk-ZQQZTMCJ.js +64 -0
  103. package/dist/chunk-ZQQZTMCJ.js.map +1 -0
  104. package/dist/clickoutside.browser.global.js +12 -0
  105. package/dist/clickoutside.browser.global.js.map +1 -0
  106. package/dist/clipboard.browser.global.js +3 -0
  107. package/dist/clipboard.browser.global.js.map +1 -0
  108. package/dist/core.browser.global.js +3 -3
  109. package/dist/core.browser.global.js.map +1 -1
  110. package/dist/draggable.browser.global.js +2 -2
  111. package/dist/draggable.browser.global.js.map +1 -1
  112. package/dist/focustrap.browser.global.js +9 -0
  113. package/dist/focustrap.browser.global.js.map +1 -0
  114. package/dist/index.cjs +1270 -50
  115. package/dist/index.cjs.map +1 -1
  116. package/dist/index.d.cts +13 -7
  117. package/dist/index.d.ts +13 -7
  118. package/dist/index.js +155 -48
  119. package/dist/index.js.map +1 -1
  120. package/dist/loaders.cjs +1151 -30
  121. package/dist/loaders.cjs.map +1 -1
  122. package/dist/loaders.js +9 -3
  123. package/dist/registry.d.cts +1 -1
  124. package/dist/registry.d.ts +1 -1
  125. package/dist/removable.browser.global.js +2 -2
  126. package/dist/removable.browser.global.js.map +1 -1
  127. package/dist/resizable.browser.global.js +2 -2
  128. package/dist/resizable.browser.global.js.map +1 -1
  129. package/dist/schemas/index.cjs +343 -1
  130. package/dist/schemas/index.cjs.map +1 -1
  131. package/dist/schemas/index.d.cts +45 -3
  132. package/dist/schemas/index.d.ts +45 -3
  133. package/dist/schemas/index.js +34 -4
  134. package/dist/schemas/index.js.map +1 -1
  135. package/dist/scrollreveal.browser.global.js +11 -0
  136. package/dist/scrollreveal.browser.global.js.map +1 -0
  137. package/dist/sortable.browser.global.js +2 -2
  138. package/dist/sortable.browser.global.js.map +1 -1
  139. package/dist/tabs.browser.global.js +3 -0
  140. package/dist/tabs.browser.global.js.map +1 -0
  141. package/dist/toggleable.browser.global.js +2 -2
  142. package/dist/toggleable.browser.global.js.map +1 -1
  143. package/dist/{types-CCc_2wAb.d.cts → types-DCCtVQS5.d.cts} +1 -1
  144. package/dist/{types-CCc_2wAb.d.ts → types-DCCtVQS5.d.ts} +1 -1
  145. package/package.json +45 -2
  146. package/src/behaviors/autodismiss.test.ts +61 -0
  147. package/src/behaviors/autodismiss.ts +107 -0
  148. package/src/behaviors/clickoutside.test.ts +52 -0
  149. package/src/behaviors/clickoutside.ts +77 -0
  150. package/src/behaviors/clipboard.test.ts +67 -0
  151. package/src/behaviors/clipboard.ts +130 -0
  152. package/src/behaviors/draggable.ts +62 -15
  153. package/src/behaviors/focustrap.test.ts +59 -0
  154. package/src/behaviors/focustrap.ts +158 -0
  155. package/src/behaviors/index.ts +37 -0
  156. package/src/behaviors/integration.test.ts +120 -45
  157. package/src/behaviors/removable.ts +4 -5
  158. package/src/behaviors/resizable.ts +63 -14
  159. package/src/behaviors/scrollreveal.test.ts +59 -0
  160. package/src/behaviors/scrollreveal.ts +78 -0
  161. package/src/behaviors/sortable.ts +61 -14
  162. package/src/behaviors/tabs.test.ts +81 -0
  163. package/src/behaviors/tabs.ts +196 -0
  164. package/src/behaviors/toggleable.ts +45 -13
  165. package/src/generated/metadata.ts +38 -7
  166. package/src/generated/types.ts +5 -5
  167. package/src/index.test.ts +143 -5
  168. package/src/index.ts +65 -13
  169. package/src/loaders.ts +18 -0
  170. package/src/registry.test.ts +26 -5
  171. package/src/schemas/autodismiss.schema.ts +66 -0
  172. package/src/schemas/clickoutside.schema.ts +39 -0
  173. package/src/schemas/clipboard.schema.ts +72 -0
  174. package/src/schemas/focustrap.schema.ts +53 -0
  175. package/src/schemas/index.ts +18 -0
  176. package/src/schemas/scrollreveal.schema.ts +60 -0
  177. package/src/schemas/tabs.schema.ts +66 -0
  178. package/src/schemas/types.ts +13 -1
  179. package/dist/chunk-2NQHIHD5.js.map +0 -1
  180. package/dist/chunk-3Q652JQB.js +0 -37
  181. package/dist/chunk-3Q652JQB.js.map +0 -1
  182. package/dist/chunk-HMRPQWQU.js +0 -37
  183. package/dist/chunk-HMRPQWQU.js.map +0 -1
  184. package/dist/chunk-MEP6B7XW.js +0 -37
  185. package/dist/chunk-MEP6B7XW.js.map +0 -1
  186. package/dist/chunk-URBOGGW6.js +0 -32
  187. package/dist/chunk-URBOGGW6.js.map +0 -1
  188. package/dist/chunk-WU37DFAL.js +0 -37
  189. package/dist/chunk-WU37DFAL.js.map +0 -1
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ describe('ClickOutside behavior', () => {
4
+ describe('registerClickOutside', () => {
5
+ it('should register with synthetic node via execute', async () => {
6
+ const { registerClickOutside } = await import('./clickoutside');
7
+ const mock = {
8
+ compileSync: vi.fn(),
9
+ execute: vi.fn().mockResolvedValue(undefined),
10
+ createContext: vi.fn().mockReturnValue({ locals: new Map(), globals: new Map() }),
11
+ };
12
+
13
+ await registerClickOutside(mock);
14
+
15
+ expect(mock.compileSync).not.toHaveBeenCalled();
16
+ expect(mock.execute).toHaveBeenCalledWith(
17
+ expect.objectContaining({
18
+ type: 'behavior',
19
+ name: 'ClickOutside',
20
+ imperativeInstaller: expect.any(Function),
21
+ }),
22
+ expect.objectContaining({ locals: expect.any(Map), globals: expect.any(Map) })
23
+ );
24
+ });
25
+
26
+ it('should throw when no runtime available', async () => {
27
+ const { registerClickOutside } = await import('./clickoutside');
28
+ await expect(registerClickOutside(undefined)).rejects.toThrowError(/LokaScript not found/);
29
+ });
30
+ });
31
+
32
+ describe('schema', () => {
33
+ it('should export valid metadata', async () => {
34
+ const { clickOutsideMetadata } = await import('./clickoutside');
35
+ expect(clickOutsideMetadata.name).toBe('ClickOutside');
36
+ expect(clickOutsideMetadata.category).toBe('ui');
37
+ expect(clickOutsideMetadata.tier).toBe('core');
38
+ });
39
+
40
+ it('should have source containing behavior declaration', async () => {
41
+ const { clickOutsideSource } = await import('./clickoutside');
42
+ expect(clickOutsideSource).toContain('behavior ClickOutside');
43
+ expect(clickOutsideSource).toContain('pointerdown');
44
+ });
45
+
46
+ it('should document clickoutside event', async () => {
47
+ const { clickOutsideMetadata } = await import('./clickoutside');
48
+ const eventNames = clickOutsideMetadata.events.map(e => e.name);
49
+ expect(eventNames).toContain('clickoutside');
50
+ });
51
+ });
52
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * ClickOutside Behavior — Imperative Implementation
3
+ *
4
+ * Fires a clickoutside event when the user clicks outside the element.
5
+ * Uses pointerdown instead of click to avoid timing issues.
6
+ */
7
+
8
+ import { clickOutsideSchema } from '../schemas/clickoutside.schema';
9
+ import type { LokaScriptInstance } from '../schemas/types';
10
+ import { resolveRuntime } from '../schemas/types';
11
+
12
+ // Re-export schema-derived values
13
+ export const clickOutsideSource = clickOutsideSchema.source;
14
+ export const clickOutsideMetadata = clickOutsideSchema;
15
+
16
+ /**
17
+ * Imperative installer for ClickOutside behavior.
18
+ */
19
+ function installClickOutside(element: HTMLElement, params: Record<string, any>): void {
20
+ let active = params.active !== false;
21
+
22
+ function onPointerDown(e: Event) {
23
+ // Auto-cleanup if element has been removed from DOM
24
+ if (!element.isConnected) {
25
+ document.removeEventListener('pointerdown', onPointerDown);
26
+ return;
27
+ }
28
+
29
+ if (active && !element.contains(e.target as Node)) {
30
+ element.dispatchEvent(new CustomEvent('clickoutside', { bubbles: true }));
31
+ }
32
+ }
33
+
34
+ document.addEventListener('pointerdown', onPointerDown);
35
+
36
+ // Programmatic activation/deactivation via custom events
37
+ element.addEventListener('clickoutside:activate', () => {
38
+ active = true;
39
+ });
40
+ element.addEventListener('clickoutside:deactivate', () => {
41
+ active = false;
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Register the ClickOutside behavior with LokaScript.
47
+ */
48
+ export async function registerClickOutside(hyperfixi?: LokaScriptInstance): Promise<void> {
49
+ const hf = hyperfixi || resolveRuntime();
50
+
51
+ if (!hf) {
52
+ throw new Error(
53
+ 'LokaScript not found. Make sure @hyperfixi/core is loaded before registering behaviors.'
54
+ );
55
+ }
56
+
57
+ const syntheticNode = {
58
+ type: 'behavior',
59
+ name: 'ClickOutside',
60
+ parameters: ['active'],
61
+ eventHandlers: [],
62
+ imperativeInstaller: installClickOutside,
63
+ };
64
+ const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
65
+ await hf.execute(syntheticNode, ctx);
66
+ }
67
+
68
+ // Auto-register when loaded as a script tag
69
+ if (resolveRuntime()) {
70
+ registerClickOutside().catch(console.error);
71
+ }
72
+
73
+ export default {
74
+ source: clickOutsideSchema.source,
75
+ metadata: clickOutsideSchema,
76
+ register: registerClickOutside,
77
+ };
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ describe('Clipboard behavior', () => {
4
+ describe('registerClipboard', () => {
5
+ it('should register with synthetic node via execute', async () => {
6
+ const { registerClipboard } = await import('./clipboard');
7
+ const mock = {
8
+ compileSync: vi.fn(),
9
+ execute: vi.fn().mockResolvedValue(undefined),
10
+ createContext: vi.fn().mockReturnValue({ locals: new Map(), globals: new Map() }),
11
+ };
12
+
13
+ await registerClipboard(mock);
14
+
15
+ expect(mock.compileSync).not.toHaveBeenCalled();
16
+ expect(mock.execute).toHaveBeenCalledWith(
17
+ expect.objectContaining({
18
+ type: 'behavior',
19
+ name: 'Clipboard',
20
+ imperativeInstaller: expect.any(Function),
21
+ }),
22
+ expect.objectContaining({ locals: expect.any(Map), globals: expect.any(Map) })
23
+ );
24
+ });
25
+
26
+ it('should throw when no runtime available', async () => {
27
+ const { registerClipboard } = await import('./clipboard');
28
+ await expect(registerClipboard(undefined)).rejects.toThrowError(/LokaScript not found/);
29
+ });
30
+
31
+ it('should fall back to manual context when createContext is missing', async () => {
32
+ const { registerClipboard } = await import('./clipboard');
33
+ const mock = {
34
+ compileSync: vi.fn(),
35
+ execute: vi.fn().mockResolvedValue(undefined),
36
+ };
37
+
38
+ await registerClipboard(mock as any);
39
+ expect(mock.execute).toHaveBeenCalledWith(
40
+ expect.anything(),
41
+ expect.objectContaining({ locals: expect.any(Map), globals: expect.any(Map) })
42
+ );
43
+ });
44
+ });
45
+
46
+ describe('schema', () => {
47
+ it('should export valid metadata', async () => {
48
+ const { clipboardMetadata } = await import('./clipboard');
49
+ expect(clipboardMetadata.name).toBe('Clipboard');
50
+ expect(clipboardMetadata.category).toBe('ui');
51
+ expect(clipboardMetadata.tier).toBe('core');
52
+ });
53
+
54
+ it('should have source containing behavior declaration', async () => {
55
+ const { clipboardSource } = await import('./clipboard');
56
+ expect(clipboardSource).toContain('behavior Clipboard');
57
+ expect(clipboardSource).toContain('clipboard:copied');
58
+ });
59
+
60
+ it('should document clipboard:copied and clipboard:error events', async () => {
61
+ const { clipboardMetadata } = await import('./clipboard');
62
+ const eventNames = clipboardMetadata.events.map(e => e.name);
63
+ expect(eventNames).toContain('clipboard:copied');
64
+ expect(eventNames).toContain('clipboard:error');
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Clipboard Behavior — Imperative Implementation
3
+ *
4
+ * Copies text to clipboard on click with visual feedback.
5
+ * Uses navigator.clipboard.writeText() with execCommand fallback.
6
+ */
7
+
8
+ import { clipboardSchema } from '../schemas/clipboard.schema';
9
+ import type { LokaScriptInstance } from '../schemas/types';
10
+ import { resolveRuntime } from '../schemas/types';
11
+
12
+ // Re-export schema-derived values
13
+ export const clipboardSource = clipboardSchema.source;
14
+ export const clipboardMetadata = clipboardSchema;
15
+
16
+ /**
17
+ * Copy text to clipboard using the modern Clipboard API,
18
+ * falling back to execCommand for older browsers.
19
+ */
20
+ async function copyToClipboard(text: string): Promise<void> {
21
+ if (navigator.clipboard?.writeText) {
22
+ await navigator.clipboard.writeText(text);
23
+ return;
24
+ }
25
+
26
+ // Fallback: temporary textarea + execCommand
27
+ const textarea = document.createElement('textarea');
28
+ textarea.value = text;
29
+ textarea.style.position = 'fixed';
30
+ textarea.style.opacity = '0';
31
+ document.body.appendChild(textarea);
32
+ textarea.select();
33
+ try {
34
+ document.execCommand('copy');
35
+ } finally {
36
+ document.body.removeChild(textarea);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Resolve a selector parameter to an HTMLElement.
42
+ */
43
+ function resolveElement(element: HTMLElement, param: unknown): HTMLElement | null {
44
+ if (!param || param === 'me') return element;
45
+ if (param instanceof HTMLElement) return param;
46
+ if (typeof param === 'string') {
47
+ return (
48
+ (element.querySelector(param) as HTMLElement) ||
49
+ (document.querySelector(param) as HTMLElement) ||
50
+ null
51
+ );
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Imperative installer for Clipboard behavior.
58
+ */
59
+ function installClipboard(element: HTMLElement, params: Record<string, any>): void {
60
+ const feedbackDuration =
61
+ typeof params.feedbackDuration === 'number' ? params.feedbackDuration : 2000;
62
+
63
+ element.addEventListener('click', async () => {
64
+ // Determine text to copy
65
+ let copyText: string;
66
+ if (params.text != null) {
67
+ copyText = String(params.text);
68
+ } else {
69
+ const sourceEl = resolveElement(element, params.source) || element;
70
+ copyText = (sourceEl as HTMLInputElement).value ?? sourceEl.textContent ?? '';
71
+ }
72
+
73
+ try {
74
+ await copyToClipboard(copyText);
75
+
76
+ // Show feedback
77
+ const feedbackEl = resolveElement(element, params.feedback) || element;
78
+ feedbackEl.classList.add('copied');
79
+ setTimeout(() => feedbackEl.classList.remove('copied'), feedbackDuration);
80
+
81
+ element.dispatchEvent(
82
+ new CustomEvent('clipboard:copied', {
83
+ bubbles: true,
84
+ detail: { text: copyText },
85
+ })
86
+ );
87
+ } catch (error) {
88
+ element.dispatchEvent(
89
+ new CustomEvent('clipboard:error', {
90
+ bubbles: true,
91
+ detail: { error },
92
+ })
93
+ );
94
+ }
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Register the Clipboard behavior with LokaScript.
100
+ */
101
+ export async function registerClipboard(hyperfixi?: LokaScriptInstance): Promise<void> {
102
+ const hf = hyperfixi || resolveRuntime();
103
+
104
+ if (!hf) {
105
+ throw new Error(
106
+ 'LokaScript not found. Make sure @hyperfixi/core is loaded before registering behaviors.'
107
+ );
108
+ }
109
+
110
+ const syntheticNode = {
111
+ type: 'behavior',
112
+ name: 'Clipboard',
113
+ parameters: ['text', 'source', 'feedback', 'feedbackDuration'],
114
+ eventHandlers: [],
115
+ imperativeInstaller: installClipboard,
116
+ };
117
+ const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
118
+ await hf.execute(syntheticNode, ctx);
119
+ }
120
+
121
+ // Auto-register when loaded as a script tag
122
+ if (resolveRuntime()) {
123
+ registerClipboard().catch(console.error);
124
+ }
125
+
126
+ export default {
127
+ source: clipboardSchema.source,
128
+ metadata: clipboardSchema,
129
+ register: registerClipboard,
130
+ };
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Draggable Behavior
2
+ * Draggable Behavior — Imperative Implementation
3
3
  *
4
- * A reusable behavior that makes elements draggable with pointer events.
5
- * Supports custom drag handles, lifecycle events, and smooth positioning.
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.
6
7
  *
7
8
  * @example
8
9
  * ```html
@@ -18,19 +19,62 @@
18
19
  */
19
20
 
20
21
  import { draggableSchema } from '../schemas/draggable.schema';
21
- import type { LokaScriptInstance, LokaScriptWindow } from '../schemas/types';
22
+ import type { LokaScriptInstance } from '../schemas/types';
23
+ import { resolveRuntime } from '../schemas/types';
22
24
 
23
25
  // Re-export schema-derived values for backwards compatibility
24
26
  export const draggableSource = draggableSchema.source;
25
27
  export const draggableMetadata = draggableSchema;
26
28
 
29
+ /**
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
+
27
72
  /**
28
73
  * Register the Draggable behavior with LokaScript.
74
+ * Uses imperative installer via synthetic behavior node.
29
75
  */
30
76
  export async function registerDraggable(hyperfixi?: LokaScriptInstance): Promise<void> {
31
- const hf =
32
- hyperfixi ||
33
- (typeof window !== 'undefined' ? (window as unknown as LokaScriptWindow).lokascript : null);
77
+ const hf = hyperfixi || resolveRuntime();
34
78
 
35
79
  if (!hf) {
36
80
  throw new Error(
@@ -38,18 +82,21 @@ export async function registerDraggable(hyperfixi?: LokaScriptInstance): Promise
38
82
  );
39
83
  }
40
84
 
41
- const result = hf.compileSync(draggableSchema.source, { traditional: true });
42
-
43
- if (!result.ok) {
44
- throw new Error(`Failed to compile Draggable behavior: ${JSON.stringify(result.errors)}`);
45
- }
46
-
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
+ };
47
94
  const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
48
- await hf.execute(result.ast, ctx);
95
+ await hf.execute(syntheticNode, ctx);
49
96
  }
50
97
 
51
98
  // Auto-register when loaded as a script tag
52
- if (typeof window !== 'undefined' && (window as unknown as LokaScriptWindow).lokascript) {
99
+ if (resolveRuntime()) {
53
100
  registerDraggable().catch(console.error);
54
101
  }
55
102
 
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+
3
+ describe('FocusTrap behavior', () => {
4
+ describe('registerFocusTrap', () => {
5
+ it('should register with synthetic node via execute', async () => {
6
+ const { registerFocusTrap } = await import('./focustrap');
7
+ const mock = {
8
+ compileSync: vi.fn(),
9
+ execute: vi.fn().mockResolvedValue(undefined),
10
+ createContext: vi.fn().mockReturnValue({ locals: new Map(), globals: new Map() }),
11
+ };
12
+
13
+ await registerFocusTrap(mock);
14
+
15
+ expect(mock.compileSync).not.toHaveBeenCalled();
16
+ expect(mock.execute).toHaveBeenCalledWith(
17
+ expect.objectContaining({
18
+ type: 'behavior',
19
+ name: 'FocusTrap',
20
+ imperativeInstaller: expect.any(Function),
21
+ }),
22
+ expect.objectContaining({ locals: expect.any(Map), globals: expect.any(Map) })
23
+ );
24
+ });
25
+
26
+ it('should throw when no runtime available', async () => {
27
+ const { registerFocusTrap } = await import('./focustrap');
28
+ await expect(registerFocusTrap(undefined)).rejects.toThrowError(/LokaScript not found/);
29
+ });
30
+ });
31
+
32
+ describe('schema', () => {
33
+ it('should export valid metadata', async () => {
34
+ const { focusTrapMetadata } = await import('./focustrap');
35
+ expect(focusTrapMetadata.name).toBe('FocusTrap');
36
+ expect(focusTrapMetadata.category).toBe('ui');
37
+ expect(focusTrapMetadata.tier).toBe('core');
38
+ });
39
+
40
+ it('should have source containing behavior declaration', async () => {
41
+ const { focusTrapSource } = await import('./focustrap');
42
+ expect(focusTrapSource).toContain('behavior FocusTrap');
43
+ });
44
+
45
+ it('should document activation events', async () => {
46
+ const { focusTrapMetadata } = await import('./focustrap');
47
+ const eventNames = focusTrapMetadata.events.map(e => e.name);
48
+ expect(eventNames).toContain('focustrap:activated');
49
+ expect(eventNames).toContain('focustrap:deactivated');
50
+ });
51
+
52
+ it('should document aria-modal requirement', async () => {
53
+ const { focusTrapMetadata } = await import('./focustrap');
54
+ expect(focusTrapMetadata.requirements).toBeDefined();
55
+ const reqs = focusTrapMetadata.requirements!.join(' ');
56
+ expect(reqs).toContain('aria-modal');
57
+ });
58
+ });
59
+ });
@@ -0,0 +1,158 @@
1
+ /**
2
+ * FocusTrap Behavior — Imperative Implementation
3
+ *
4
+ * Confines Tab navigation inside an element, manages aria-modal,
5
+ * and restores focus on deactivation.
6
+ */
7
+
8
+ import { focusTrapSchema } from '../schemas/focustrap.schema';
9
+ import type { LokaScriptInstance } from '../schemas/types';
10
+ import { resolveRuntime } from '../schemas/types';
11
+
12
+ // Re-export schema-derived values
13
+ export const focusTrapSource = focusTrapSchema.source;
14
+ export const focusTrapMetadata = focusTrapSchema;
15
+
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
+ /**
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.
128
+ */
129
+ export async function registerFocusTrap(hyperfixi?: LokaScriptInstance): Promise<void> {
130
+ const hf = hyperfixi || resolveRuntime();
131
+
132
+ if (!hf) {
133
+ throw new Error(
134
+ 'LokaScript not found. Make sure @hyperfixi/core is loaded before registering behaviors.'
135
+ );
136
+ }
137
+
138
+ const syntheticNode = {
139
+ type: 'behavior',
140
+ name: 'FocusTrap',
141
+ parameters: ['active', 'initialFocus', 'returnFocus'],
142
+ eventHandlers: [],
143
+ imperativeInstaller: installFocusTrap,
144
+ };
145
+ const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
146
+ await hf.execute(syntheticNode, ctx);
147
+ }
148
+
149
+ // Auto-register when loaded as a script tag
150
+ if (resolveRuntime()) {
151
+ registerFocusTrap().catch(console.error);
152
+ }
153
+
154
+ export default {
155
+ source: focusTrapSchema.source,
156
+ metadata: focusTrapSchema,
157
+ register: registerFocusTrap,
158
+ };
@@ -38,3 +38,40 @@ export {
38
38
  registerToggleable,
39
39
  default as Toggleable,
40
40
  } from './toggleable';
41
+
42
+ export {
43
+ clipboardSource,
44
+ clipboardMetadata,
45
+ registerClipboard,
46
+ default as Clipboard,
47
+ } from './clipboard';
48
+
49
+ export {
50
+ autoDismissSource,
51
+ autoDismissMetadata,
52
+ registerAutoDismiss,
53
+ default as AutoDismiss,
54
+ } from './autodismiss';
55
+
56
+ export {
57
+ clickOutsideSource,
58
+ clickOutsideMetadata,
59
+ registerClickOutside,
60
+ default as ClickOutside,
61
+ } from './clickoutside';
62
+
63
+ export {
64
+ focusTrapSource,
65
+ focusTrapMetadata,
66
+ registerFocusTrap,
67
+ default as FocusTrap,
68
+ } from './focustrap';
69
+
70
+ export {
71
+ scrollRevealSource,
72
+ scrollRevealMetadata,
73
+ registerScrollReveal,
74
+ default as ScrollReveal,
75
+ } from './scrollreveal';
76
+
77
+ export { tabsSource, tabsMetadata, registerTabs, default as Tabs } from './tabs';