@hyperfixi/behaviors 2.0.0 → 2.2.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.
- package/dist/all.browser.global.js +35 -6
- package/dist/all.browser.global.js.map +1 -1
- package/dist/autodismiss.browser.global.js +3 -0
- package/dist/autodismiss.browser.global.js.map +1 -0
- package/dist/behaviors/autodismiss.cjs +177 -0
- package/dist/behaviors/autodismiss.cjs.map +1 -0
- package/dist/behaviors/autodismiss.d.cts +15 -0
- package/dist/behaviors/autodismiss.d.ts +15 -0
- package/dist/behaviors/autodismiss.js +15 -0
- package/dist/behaviors/autodismiss.js.map +1 -0
- package/dist/behaviors/clickoutside.cjs +123 -0
- package/dist/behaviors/clickoutside.cjs.map +1 -0
- package/dist/behaviors/clickoutside.d.cts +15 -0
- package/dist/behaviors/clickoutside.d.ts +15 -0
- package/dist/behaviors/clickoutside.js +15 -0
- package/dist/behaviors/clickoutside.js.map +1 -0
- package/dist/behaviors/clipboard.cjs +193 -0
- package/dist/behaviors/clipboard.cjs.map +1 -0
- package/dist/behaviors/clipboard.d.cts +15 -0
- package/dist/behaviors/clipboard.d.ts +15 -0
- package/dist/behaviors/clipboard.js +15 -0
- package/dist/behaviors/clipboard.js.map +1 -0
- package/dist/behaviors/draggable.cjs +49 -7
- package/dist/behaviors/draggable.cjs.map +1 -1
- package/dist/behaviors/draggable.d.cts +2 -1
- package/dist/behaviors/draggable.d.ts +2 -1
- package/dist/behaviors/draggable.js +2 -1
- package/dist/behaviors/focustrap.cjs +191 -0
- package/dist/behaviors/focustrap.cjs.map +1 -0
- package/dist/behaviors/focustrap.d.cts +15 -0
- package/dist/behaviors/focustrap.d.ts +15 -0
- package/dist/behaviors/focustrap.js +15 -0
- package/dist/behaviors/focustrap.js.map +1 -0
- package/dist/behaviors/removable.cjs +9 -2
- package/dist/behaviors/removable.cjs.map +1 -1
- package/dist/behaviors/removable.d.cts +1 -1
- package/dist/behaviors/removable.d.ts +1 -1
- package/dist/behaviors/removable.js +2 -1
- package/dist/behaviors/resizable.cjs +54 -7
- package/dist/behaviors/resizable.cjs.map +1 -1
- package/dist/behaviors/resizable.d.cts +1 -1
- package/dist/behaviors/resizable.d.ts +1 -1
- package/dist/behaviors/resizable.js +2 -1
- package/dist/behaviors/scrollreveal.cjs +149 -0
- package/dist/behaviors/scrollreveal.cjs.map +1 -0
- package/dist/behaviors/scrollreveal.d.cts +15 -0
- package/dist/behaviors/scrollreveal.d.ts +15 -0
- package/dist/behaviors/scrollreveal.js +15 -0
- package/dist/behaviors/scrollreveal.js.map +1 -0
- package/dist/behaviors/sortable.cjs +49 -7
- package/dist/behaviors/sortable.cjs.map +1 -1
- package/dist/behaviors/sortable.d.cts +1 -1
- package/dist/behaviors/sortable.d.ts +1 -1
- package/dist/behaviors/sortable.js +2 -1
- package/dist/behaviors/tabs.cjs +247 -0
- package/dist/behaviors/tabs.cjs.map +1 -0
- package/dist/behaviors/tabs.d.cts +15 -0
- package/dist/behaviors/tabs.d.ts +15 -0
- package/dist/behaviors/tabs.js +15 -0
- package/dist/behaviors/tabs.js.map +1 -0
- package/dist/behaviors/toggleable.cjs +36 -7
- package/dist/behaviors/toggleable.cjs.map +1 -1
- package/dist/behaviors/toggleable.d.cts +2 -1
- package/dist/behaviors/toggleable.d.ts +2 -1
- package/dist/behaviors/toggleable.js +2 -1
- package/dist/chunk-244WSZOL.js +67 -0
- package/dist/chunk-244WSZOL.js.map +1 -0
- package/dist/chunk-2NSKQMNM.js +99 -0
- package/dist/chunk-2NSKQMNM.js.map +1 -0
- package/dist/chunk-64DIQGQI.js +37 -0
- package/dist/chunk-64DIQGQI.js.map +1 -0
- package/dist/chunk-6TJXDCAQ.js +11 -0
- package/dist/chunk-6TJXDCAQ.js.map +1 -0
- package/dist/chunk-7JDV2THA.js +62 -0
- package/dist/chunk-7JDV2THA.js.map +1 -0
- package/dist/chunk-AFGA54SA.js +116 -0
- package/dist/chunk-AFGA54SA.js.map +1 -0
- package/dist/chunk-DYHBKDKB.js +62 -0
- package/dist/chunk-DYHBKDKB.js.map +1 -0
- package/dist/chunk-GPTHAUWN.js +58 -0
- package/dist/chunk-GPTHAUWN.js.map +1 -0
- package/dist/chunk-GVJPA2EK.js +68 -0
- package/dist/chunk-GVJPA2EK.js.map +1 -0
- package/dist/chunk-H2MCHR5A.js +89 -0
- package/dist/chunk-H2MCHR5A.js.map +1 -0
- package/dist/{chunk-2NQHIHD5.js → chunk-H5Y5ARYG.js} +6 -3
- package/dist/chunk-H5Y5ARYG.js.map +1 -0
- package/dist/chunk-JKBWIJFI.js +155 -0
- package/dist/chunk-JKBWIJFI.js.map +1 -0
- package/dist/chunk-QKDYW4VM.js +70 -0
- package/dist/chunk-QKDYW4VM.js.map +1 -0
- package/dist/chunk-RB7OB6OD.js +51 -0
- package/dist/chunk-RB7OB6OD.js.map +1 -0
- package/dist/chunk-RPERQTFU.js +75 -0
- package/dist/chunk-RPERQTFU.js.map +1 -0
- package/dist/chunk-S4QDV2MH.js +75 -0
- package/dist/chunk-S4QDV2MH.js.map +1 -0
- package/dist/chunk-WTUVPVSD.js +80 -0
- package/dist/chunk-WTUVPVSD.js.map +1 -0
- package/dist/chunk-YHQRFUWV.js +62 -0
- package/dist/chunk-YHQRFUWV.js.map +1 -0
- package/dist/chunk-ZQQZTMCJ.js +64 -0
- package/dist/chunk-ZQQZTMCJ.js.map +1 -0
- package/dist/clickoutside.browser.global.js +12 -0
- package/dist/clickoutside.browser.global.js.map +1 -0
- package/dist/clipboard.browser.global.js +3 -0
- package/dist/clipboard.browser.global.js.map +1 -0
- package/dist/core.browser.global.js +3 -3
- package/dist/core.browser.global.js.map +1 -1
- package/dist/draggable.browser.global.js +2 -2
- package/dist/draggable.browser.global.js.map +1 -1
- package/dist/focustrap.browser.global.js +9 -0
- package/dist/focustrap.browser.global.js.map +1 -0
- package/dist/index.cjs +1270 -50
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -7
- package/dist/index.d.ts +13 -7
- package/dist/index.js +155 -48
- package/dist/index.js.map +1 -1
- package/dist/loaders.cjs +1151 -30
- package/dist/loaders.cjs.map +1 -1
- package/dist/loaders.js +9 -3
- package/dist/registry.d.cts +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/removable.browser.global.js +2 -2
- package/dist/removable.browser.global.js.map +1 -1
- package/dist/resizable.browser.global.js +2 -2
- package/dist/resizable.browser.global.js.map +1 -1
- package/dist/schemas/index.cjs +343 -1
- package/dist/schemas/index.cjs.map +1 -1
- package/dist/schemas/index.d.cts +45 -3
- package/dist/schemas/index.d.ts +45 -3
- package/dist/schemas/index.js +34 -4
- package/dist/schemas/index.js.map +1 -1
- package/dist/scrollreveal.browser.global.js +11 -0
- package/dist/scrollreveal.browser.global.js.map +1 -0
- package/dist/sortable.browser.global.js +2 -2
- package/dist/sortable.browser.global.js.map +1 -1
- package/dist/tabs.browser.global.js +3 -0
- package/dist/tabs.browser.global.js.map +1 -0
- package/dist/toggleable.browser.global.js +2 -2
- package/dist/toggleable.browser.global.js.map +1 -1
- package/dist/{types-CCc_2wAb.d.cts → types-DCCtVQS5.d.cts} +1 -1
- package/dist/{types-CCc_2wAb.d.ts → types-DCCtVQS5.d.ts} +1 -1
- package/package.json +45 -2
- package/src/behaviors/autodismiss.test.ts +61 -0
- package/src/behaviors/autodismiss.ts +107 -0
- package/src/behaviors/clickoutside.test.ts +52 -0
- package/src/behaviors/clickoutside.ts +77 -0
- package/src/behaviors/clipboard.test.ts +67 -0
- package/src/behaviors/clipboard.ts +130 -0
- package/src/behaviors/draggable.ts +62 -15
- package/src/behaviors/focustrap.test.ts +59 -0
- package/src/behaviors/focustrap.ts +158 -0
- package/src/behaviors/index.ts +37 -0
- package/src/behaviors/integration.test.ts +120 -45
- package/src/behaviors/removable.ts +4 -5
- package/src/behaviors/resizable.ts +63 -14
- package/src/behaviors/scrollreveal.test.ts +59 -0
- package/src/behaviors/scrollreveal.ts +78 -0
- package/src/behaviors/sortable.ts +61 -14
- package/src/behaviors/tabs.test.ts +81 -0
- package/src/behaviors/tabs.ts +196 -0
- package/src/behaviors/toggleable.ts +45 -13
- package/src/generated/metadata.ts +38 -7
- package/src/generated/types.ts +5 -5
- package/src/index.test.ts +143 -5
- package/src/index.ts +65 -13
- package/src/loaders.ts +18 -0
- package/src/registry.test.ts +26 -5
- package/src/schemas/autodismiss.schema.ts +66 -0
- package/src/schemas/clickoutside.schema.ts +39 -0
- package/src/schemas/clipboard.schema.ts +72 -0
- package/src/schemas/focustrap.schema.ts +53 -0
- package/src/schemas/index.ts +18 -0
- package/src/schemas/scrollreveal.schema.ts +60 -0
- package/src/schemas/tabs.schema.ts +66 -0
- package/src/schemas/types.ts +13 -1
- package/dist/chunk-2NQHIHD5.js.map +0 -1
- package/dist/chunk-3Q652JQB.js +0 -37
- package/dist/chunk-3Q652JQB.js.map +0 -1
- package/dist/chunk-HMRPQWQU.js +0 -37
- package/dist/chunk-HMRPQWQU.js.map +0 -1
- package/dist/chunk-MEP6B7XW.js +0 -37
- package/dist/chunk-MEP6B7XW.js.map +0 -1
- package/dist/chunk-URBOGGW6.js +0 -32
- package/dist/chunk-URBOGGW6.js.map +0 -1
- package/dist/chunk-WU37DFAL.js +0 -37
- 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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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(
|
|
95
|
+
await hf.execute(syntheticNode, ctx);
|
|
49
96
|
}
|
|
50
97
|
|
|
51
98
|
// Auto-register when loaded as a script tag
|
|
52
|
-
if (
|
|
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
|
+
};
|
package/src/behaviors/index.ts
CHANGED
|
@@ -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';
|