@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.
- package/README.md +100 -0
- package/dist/all.browser.global.js +43 -40
- package/dist/all.browser.global.js.map +1 -1
- package/dist/autodismiss.browser.global.js +2 -2
- package/dist/autodismiss.browser.global.js.map +1 -1
- package/dist/behaviors/autodismiss.cjs +5 -54
- package/dist/behaviors/autodismiss.cjs.map +1 -1
- package/dist/behaviors/autodismiss.d.cts +3 -2
- package/dist/behaviors/autodismiss.d.ts +3 -2
- package/dist/behaviors/autodismiss.js +1 -1
- package/dist/behaviors/clickoutside.cjs +6 -28
- package/dist/behaviors/clickoutside.cjs.map +1 -1
- package/dist/behaviors/clickoutside.d.cts +3 -2
- package/dist/behaviors/clickoutside.d.ts +3 -2
- package/dist/behaviors/clickoutside.js +2 -2
- package/dist/behaviors/clipboard.cjs +30 -86
- package/dist/behaviors/clipboard.cjs.map +1 -1
- package/dist/behaviors/clipboard.d.cts +3 -2
- package/dist/behaviors/clipboard.d.ts +3 -2
- package/dist/behaviors/clipboard.js +2 -2
- package/dist/behaviors/draggable.cjs +6 -41
- package/dist/behaviors/draggable.cjs.map +1 -1
- package/dist/behaviors/draggable.d.cts +3 -3
- package/dist/behaviors/draggable.d.ts +3 -3
- package/dist/behaviors/draggable.js +2 -2
- package/dist/behaviors/focustrap.cjs +5 -81
- package/dist/behaviors/focustrap.cjs.map +1 -1
- package/dist/behaviors/focustrap.d.cts +3 -2
- package/dist/behaviors/focustrap.d.ts +3 -2
- package/dist/behaviors/focustrap.js +1 -1
- package/dist/behaviors/removable.d.cts +1 -1
- package/dist/behaviors/removable.d.ts +1 -1
- package/dist/behaviors/resizable.cjs +9 -49
- package/dist/behaviors/resizable.cjs.map +1 -1
- package/dist/behaviors/resizable.d.cts +3 -2
- package/dist/behaviors/resizable.d.ts +3 -2
- package/dist/behaviors/resizable.js +2 -2
- package/dist/behaviors/scrollreveal.cjs +5 -32
- package/dist/behaviors/scrollreveal.cjs.map +1 -1
- package/dist/behaviors/scrollreveal.d.cts +3 -2
- package/dist/behaviors/scrollreveal.d.ts +3 -2
- package/dist/behaviors/scrollreveal.js +1 -1
- package/dist/behaviors/sortable.cjs +8 -43
- package/dist/behaviors/sortable.cjs.map +1 -1
- package/dist/behaviors/sortable.d.cts +3 -2
- package/dist/behaviors/sortable.d.ts +3 -2
- package/dist/behaviors/sortable.js +2 -2
- package/dist/behaviors/tabs.cjs +5 -120
- package/dist/behaviors/tabs.cjs.map +1 -1
- package/dist/behaviors/tabs.d.cts +3 -2
- package/dist/behaviors/tabs.d.ts +3 -2
- package/dist/behaviors/tabs.js +1 -1
- package/dist/behaviors/toggleable.cjs +5 -27
- package/dist/behaviors/toggleable.cjs.map +1 -1
- package/dist/behaviors/toggleable.d.cts +3 -3
- package/dist/behaviors/toggleable.d.ts +3 -3
- package/dist/behaviors/toggleable.js +1 -1
- package/dist/{chunk-SLOXO6IY.js → chunk-26IX7QU7.js} +2 -2
- package/dist/{chunk-SLOXO6IY.js.map → chunk-26IX7QU7.js.map} +1 -1
- package/dist/chunk-2HUF6RMI.js +40 -0
- package/dist/chunk-2HUF6RMI.js.map +1 -0
- package/dist/{chunk-ZBSWTV6I.js → chunk-5LFOFVZ3.js} +5 -5
- package/dist/chunk-5LFOFVZ3.js.map +1 -0
- package/dist/{chunk-BBIIOGMF.js → chunk-6EG2MNC7.js} +6 -6
- package/dist/chunk-7NGOA3T5.js +40 -0
- package/dist/chunk-7NGOA3T5.js.map +1 -0
- package/dist/{chunk-ZZ2WSAX3.js → chunk-7TPDLB7W.js} +7 -29
- package/dist/chunk-7TPDLB7W.js.map +1 -0
- package/dist/chunk-CR3J7VV6.js +40 -0
- package/dist/chunk-CR3J7VV6.js.map +1 -0
- package/dist/chunk-EDBG2F57.js +40 -0
- package/dist/chunk-EDBG2F57.js.map +1 -0
- package/dist/{chunk-G6WR2AYR.js → chunk-FBQ3X3UY.js} +2 -2
- package/dist/chunk-FBQ3X3UY.js.map +1 -0
- package/dist/chunk-HX663BJ4.js +40 -0
- package/dist/chunk-HX663BJ4.js.map +1 -0
- package/dist/chunk-KROW43CG.js +40 -0
- package/dist/chunk-KROW43CG.js.map +1 -0
- package/dist/chunk-ORBJ7MRW.js +40 -0
- package/dist/chunk-ORBJ7MRW.js.map +1 -0
- package/dist/{chunk-V2M4W3LO.js → chunk-PDMOOWFW.js} +4 -4
- package/dist/chunk-PDMOOWFW.js.map +1 -0
- package/dist/chunk-SLSCZV4A.js +40 -0
- package/dist/chunk-SLSCZV4A.js.map +1 -0
- package/dist/chunk-WKBRZGGL.js +40 -0
- package/dist/chunk-WKBRZGGL.js.map +1 -0
- package/dist/{chunk-WOTZO7K4.js → chunk-YXS27XM6.js} +26 -23
- package/dist/chunk-YXS27XM6.js.map +1 -0
- package/dist/clickoutside.browser.global.js +3 -3
- package/dist/clickoutside.browser.global.js.map +1 -1
- package/dist/clipboard.browser.global.js +27 -24
- package/dist/clipboard.browser.global.js.map +1 -1
- 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 +2 -2
- package/dist/focustrap.browser.global.js.map +1 -1
- package/dist/index.cjs +120 -563
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +41 -3
- package/dist/index.d.ts +41 -3
- package/dist/index.js +44 -16
- package/dist/index.js.map +1 -1
- package/dist/loaders.cjs +86 -563
- package/dist/loaders.cjs.map +1 -1
- package/dist/loaders.js +6 -6
- package/dist/registry.d.cts +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/resizable.browser.global.js +5 -5
- package/dist/resizable.browser.global.js.map +1 -1
- package/dist/resolver.browser.global.js +34 -31
- package/dist/resolver.browser.global.js.map +1 -1
- package/dist/schemas/index.cjs +34 -31
- package/dist/schemas/index.cjs.map +1 -1
- package/dist/schemas/index.d.cts +2 -2
- package/dist/schemas/index.d.ts +2 -2
- package/dist/schemas/index.js +5 -5
- package/dist/scrollreveal.browser.global.js +2 -2
- package/dist/scrollreveal.browser.global.js.map +1 -1
- package/dist/sortable.browser.global.js +4 -4
- package/dist/sortable.browser.global.js.map +1 -1
- package/dist/tabs.browser.global.js +2 -2
- package/dist/tabs.browser.global.js.map +1 -1
- package/dist/toggleable.browser.global.js +2 -2
- package/dist/toggleable.browser.global.js.map +1 -1
- package/dist/{types-BY-jB5FV.d.ts → types-DCCtVQS5.d.cts} +1 -1
- package/dist/{types-BY-jB5FV.d.cts → types-DCCtVQS5.d.ts} +1 -1
- package/package.json +4 -4
- package/src/behaviors/autodismiss.test.ts +15 -9
- package/src/behaviors/autodismiss.ts +15 -70
- package/src/behaviors/clickoutside.test.ts +17 -9
- package/src/behaviors/clickoutside.ts +13 -39
- package/src/behaviors/clipboard.test.ts +16 -10
- package/src/behaviors/clipboard.ts +15 -93
- package/src/behaviors/curated-runtime.test.ts +158 -0
- package/src/behaviors/draggable.ts +15 -59
- package/src/behaviors/experimental-runtime.test.ts +136 -0
- package/src/behaviors/focustrap.test.ts +15 -9
- package/src/behaviors/focustrap.ts +17 -121
- package/src/behaviors/integration.test.ts +9 -64
- package/src/behaviors/optional-runtime.test.ts +251 -0
- package/src/behaviors/resizable.ts +14 -61
- package/src/behaviors/scrollreveal.test.ts +17 -9
- package/src/behaviors/scrollreveal.ts +17 -41
- package/src/behaviors/sortable.ts +15 -58
- package/src/behaviors/tabs.test.ts +16 -10
- package/src/behaviors/tabs.ts +18 -160
- package/src/behaviors/toggleable.ts +14 -41
- package/src/curation.test.ts +45 -0
- package/src/curation.ts +58 -0
- package/src/index.ts +14 -0
- package/src/registry.test.ts +6 -11
- package/src/schemas/clickoutside.schema.ts +1 -1
- package/src/schemas/clipboard.schema.ts +25 -22
- package/src/schemas/draggable.schema.ts +2 -1
- package/src/schemas/resizable.schema.ts +5 -4
- package/src/schemas/sortable.schema.ts +4 -3
- package/dist/chunk-7O7UOSBH.js +0 -99
- package/dist/chunk-7O7UOSBH.js.map +0 -1
- package/dist/chunk-EKKJVXDZ.js +0 -67
- package/dist/chunk-EKKJVXDZ.js.map +0 -1
- package/dist/chunk-F3CASJ7Q.js +0 -116
- package/dist/chunk-F3CASJ7Q.js.map +0 -1
- package/dist/chunk-G6WR2AYR.js.map +0 -1
- package/dist/chunk-GALKOMJ5.js +0 -62
- package/dist/chunk-GALKOMJ5.js.map +0 -1
- package/dist/chunk-IMDFEGIT.js +0 -75
- package/dist/chunk-IMDFEGIT.js.map +0 -1
- package/dist/chunk-IT6TYVMW.js +0 -80
- package/dist/chunk-IT6TYVMW.js.map +0 -1
- package/dist/chunk-LQJOQTBZ.js +0 -89
- package/dist/chunk-LQJOQTBZ.js.map +0 -1
- package/dist/chunk-S4QDV2MH.js +0 -75
- package/dist/chunk-S4QDV2MH.js.map +0 -1
- package/dist/chunk-V2M4W3LO.js.map +0 -1
- package/dist/chunk-WOTZO7K4.js.map +0 -1
- package/dist/chunk-WPOQIMCC.js +0 -155
- package/dist/chunk-WPOQIMCC.js.map +0 -1
- package/dist/chunk-ZBSWTV6I.js.map +0 -1
- package/dist/chunk-ZZ2WSAX3.js.map +0 -1
- /package/dist/{chunk-BBIIOGMF.js.map → chunk-6EG2MNC7.js.map} +0 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
/**
|
|
3
|
+
* Optional behaviors — REAL runtime behavior tests.
|
|
4
|
+
*
|
|
5
|
+
* The optional three (FocusTrap / ScrollReveal / Tabs) were converted from
|
|
6
|
+
* imperative installers onto the single source-compiled runtime path
|
|
7
|
+
* (BEHAVIORS_CONSOLIDATION_PLAN.md §3d follow-up). Like curated-runtime.test.ts,
|
|
8
|
+
* these exercise each behavior's actual hyperscript `source` through the real
|
|
9
|
+
* @hyperfixi/core runtime — register → install on an element → drive the DOM →
|
|
10
|
+
* assert both the effect AND the documented lifecycle events.
|
|
11
|
+
*
|
|
12
|
+
* This is the "parses ≠ works" guard (§2/§5): the mock-based unit tests only prove
|
|
13
|
+
* `register*()` calls compileSync; these prove the `init`-block `js()` bodies (the
|
|
14
|
+
* focus model, the IntersectionObserver, the ARIA/keyboard wiring) actually run
|
|
15
|
+
* correctly once compiled — exactly the class of bug the imperative path masked.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest';
|
|
18
|
+
import { hyperscript } from '@hyperfixi/core';
|
|
19
|
+
import { registerFocusTrap } from './focustrap';
|
|
20
|
+
import { registerScrollReveal } from './scrollreveal';
|
|
21
|
+
import { registerTabs } from './tabs';
|
|
22
|
+
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
const hf = hyperscript as any;
|
|
25
|
+
|
|
26
|
+
const tick = (ms = 30) => new Promise(r => setTimeout(r, ms));
|
|
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
|
+
// Behavior registry is a runtime singleton; register the optional set once.
|
|
36
|
+
await registerFocusTrap(hf);
|
|
37
|
+
await registerScrollReveal(hf);
|
|
38
|
+
await registerTabs(hf);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
document.body.innerHTML = '';
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('FocusTrap — runtime', () => {
|
|
46
|
+
function buildTrap(): { trap: HTMLElement; buttons: HTMLButtonElement[] } {
|
|
47
|
+
const trap = document.createElement('div');
|
|
48
|
+
const buttons = [0, 1, 2].map(i => {
|
|
49
|
+
const b = document.createElement('button');
|
|
50
|
+
b.textContent = `b${i}`;
|
|
51
|
+
trap.appendChild(b);
|
|
52
|
+
return b;
|
|
53
|
+
});
|
|
54
|
+
document.body.appendChild(trap);
|
|
55
|
+
return { trap, buttons };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it('activates on install: sets aria-modal, focuses first, fires focustrap:activated', async () => {
|
|
59
|
+
const { trap, buttons } = buildTrap();
|
|
60
|
+
const activated = vi.fn();
|
|
61
|
+
trap.addEventListener('focustrap:activated', activated);
|
|
62
|
+
|
|
63
|
+
await install('install FocusTrap', trap);
|
|
64
|
+
|
|
65
|
+
expect(trap.getAttribute('aria-modal')).toBe('true');
|
|
66
|
+
expect(activated).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(document.activeElement).toBe(buttons[0]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('traps Tab: wraps forward off the last element and backward off the first', async () => {
|
|
71
|
+
const { trap, buttons } = buildTrap();
|
|
72
|
+
await install('install FocusTrap', trap);
|
|
73
|
+
|
|
74
|
+
// Forward Tab from first → second.
|
|
75
|
+
buttons[0].focus();
|
|
76
|
+
trap.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
|
77
|
+
expect(document.activeElement).toBe(buttons[1]);
|
|
78
|
+
|
|
79
|
+
// Forward Tab off the last element → wraps to first.
|
|
80
|
+
buttons[2].focus();
|
|
81
|
+
trap.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
|
|
82
|
+
expect(document.activeElement).toBe(buttons[0]);
|
|
83
|
+
|
|
84
|
+
// Shift+Tab off the first element → wraps to last.
|
|
85
|
+
buttons[0].focus();
|
|
86
|
+
trap.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }));
|
|
87
|
+
expect(document.activeElement).toBe(buttons[2]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('deactivates via custom event: removes aria-modal and fires focustrap:deactivated', async () => {
|
|
91
|
+
const { trap } = buildTrap();
|
|
92
|
+
const deactivated = vi.fn();
|
|
93
|
+
trap.addEventListener('focustrap:deactivated', deactivated);
|
|
94
|
+
|
|
95
|
+
await install('install FocusTrap', trap);
|
|
96
|
+
expect(trap.getAttribute('aria-modal')).toBe('true');
|
|
97
|
+
|
|
98
|
+
trap.dispatchEvent(new CustomEvent('focustrap:deactivate'));
|
|
99
|
+
await tick();
|
|
100
|
+
|
|
101
|
+
expect(trap.hasAttribute('aria-modal')).toBe(false);
|
|
102
|
+
expect(deactivated).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('ScrollReveal — runtime', () => {
|
|
107
|
+
// happy-dom ships an IntersectionObserver that never fires its callback (no
|
|
108
|
+
// layout engine). Stub it so we can drive the intersection deterministically and
|
|
109
|
+
// assert the js() body's class/event/disconnect logic actually runs.
|
|
110
|
+
let lastCallback: ((entries: Array<{ isIntersecting: boolean }>) => void) | null = null;
|
|
111
|
+
let disconnected = false;
|
|
112
|
+
let observed: Element | null = null;
|
|
113
|
+
|
|
114
|
+
class MockIntersectionObserver {
|
|
115
|
+
constructor(cb: (entries: Array<{ isIntersecting: boolean }>) => void) {
|
|
116
|
+
lastCallback = cb;
|
|
117
|
+
}
|
|
118
|
+
observe(el: Element) {
|
|
119
|
+
observed = el;
|
|
120
|
+
}
|
|
121
|
+
disconnect() {
|
|
122
|
+
disconnected = true;
|
|
123
|
+
}
|
|
124
|
+
unobserve() {}
|
|
125
|
+
takeRecords() {
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
beforeAll(() => {
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
132
|
+
(globalThis as any).IntersectionObserver = MockIntersectionObserver;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
afterEach(() => {
|
|
136
|
+
lastCallback = null;
|
|
137
|
+
disconnected = false;
|
|
138
|
+
observed = null;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('observes the element and adds the class + fires enter on intersection (once)', async () => {
|
|
142
|
+
const el = document.createElement('div');
|
|
143
|
+
document.body.appendChild(el);
|
|
144
|
+
const enter = vi.fn();
|
|
145
|
+
el.addEventListener('scrollreveal:enter', enter);
|
|
146
|
+
|
|
147
|
+
await install('install ScrollReveal', el);
|
|
148
|
+
|
|
149
|
+
expect(observed).toBe(el);
|
|
150
|
+
expect(lastCallback).toBeTypeOf('function');
|
|
151
|
+
|
|
152
|
+
// Simulate the element scrolling into view.
|
|
153
|
+
lastCallback!([{ isIntersecting: true }]);
|
|
154
|
+
|
|
155
|
+
expect(el.classList.contains('revealed')).toBe(true);
|
|
156
|
+
expect(enter).toHaveBeenCalledTimes(1);
|
|
157
|
+
// once defaults to true → observer disconnects after the first reveal.
|
|
158
|
+
expect(disconnected).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('honors a custom class parameter', async () => {
|
|
162
|
+
const el = document.createElement('div');
|
|
163
|
+
document.body.appendChild(el);
|
|
164
|
+
|
|
165
|
+
await install('install ScrollReveal(cls: "shown")', el);
|
|
166
|
+
lastCallback!([{ isIntersecting: true }]);
|
|
167
|
+
|
|
168
|
+
expect(el.classList.contains('shown')).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('Tabs — runtime', () => {
|
|
173
|
+
function buildTabs(): {
|
|
174
|
+
root: HTMLElement;
|
|
175
|
+
tabs: HTMLElement[];
|
|
176
|
+
panels: HTMLElement[];
|
|
177
|
+
} {
|
|
178
|
+
const root = document.createElement('div');
|
|
179
|
+
const tablist = document.createElement('div');
|
|
180
|
+
tablist.setAttribute('role', 'tablist');
|
|
181
|
+
root.appendChild(tablist);
|
|
182
|
+
|
|
183
|
+
const tabs: HTMLElement[] = [];
|
|
184
|
+
const panels: HTMLElement[] = [];
|
|
185
|
+
for (let i = 0; i < 3; i++) {
|
|
186
|
+
const tab = document.createElement('button');
|
|
187
|
+
tab.setAttribute('role', 'tab');
|
|
188
|
+
tab.textContent = `Tab ${i}`;
|
|
189
|
+
tablist.appendChild(tab);
|
|
190
|
+
tabs.push(tab);
|
|
191
|
+
|
|
192
|
+
const panel = document.createElement('div');
|
|
193
|
+
panel.setAttribute('role', 'tabpanel');
|
|
194
|
+
panel.textContent = `Panel ${i}`;
|
|
195
|
+
root.appendChild(panel);
|
|
196
|
+
panels.push(panel);
|
|
197
|
+
}
|
|
198
|
+
document.body.appendChild(root);
|
|
199
|
+
return { root, tabs, panels };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it('wires ARIA on install: first tab selected, roving tabindex, controls/labelledby', async () => {
|
|
203
|
+
const { root, tabs, panels } = buildTabs();
|
|
204
|
+
|
|
205
|
+
await install('install Tabs', root);
|
|
206
|
+
|
|
207
|
+
expect(tabs[0].getAttribute('aria-selected')).toBe('true');
|
|
208
|
+
expect(tabs[0].getAttribute('tabindex')).toBe('0');
|
|
209
|
+
expect(tabs[1].getAttribute('aria-selected')).toBe('false');
|
|
210
|
+
expect(tabs[1].getAttribute('tabindex')).toBe('-1');
|
|
211
|
+
|
|
212
|
+
// aria-controls / aria-labelledby cross-link tab[i] <-> panel[i].
|
|
213
|
+
expect(tabs[0].getAttribute('aria-controls')).toBe(panels[0].id);
|
|
214
|
+
expect(panels[0].getAttribute('aria-labelledby')).toBe(tabs[0].id);
|
|
215
|
+
expect(panels[0].getAttribute('aria-hidden')).toBe('false');
|
|
216
|
+
expect(panels[1].getAttribute('aria-hidden')).toBe('true');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('selects a tab on click and fires tabs:changed', async () => {
|
|
220
|
+
const { root, tabs, panels } = buildTabs();
|
|
221
|
+
const changed = vi.fn();
|
|
222
|
+
root.addEventListener('tabs:changed', changed);
|
|
223
|
+
|
|
224
|
+
await install('install Tabs', root);
|
|
225
|
+
|
|
226
|
+
tabs[1].click();
|
|
227
|
+
await tick();
|
|
228
|
+
|
|
229
|
+
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
|
|
230
|
+
expect(tabs[1].classList.contains('active')).toBe(true);
|
|
231
|
+
expect(tabs[0].getAttribute('aria-selected')).toBe('false');
|
|
232
|
+
expect(panels[1].getAttribute('aria-hidden')).toBe('false');
|
|
233
|
+
expect(changed).toHaveBeenCalledTimes(1);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('navigates with ArrowRight and is cancelable via tabs:change', async () => {
|
|
237
|
+
const { root, tabs } = buildTabs();
|
|
238
|
+
await install('install Tabs', root);
|
|
239
|
+
|
|
240
|
+
// ArrowRight on the tablist advances selection.
|
|
241
|
+
const tablist = root.querySelector('[role="tablist"]')!;
|
|
242
|
+
tablist.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
243
|
+
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
|
|
244
|
+
|
|
245
|
+
// preventDefault on the cancelable tabs:change blocks the next switch.
|
|
246
|
+
root.addEventListener('tabs:change', e => e.preventDefault(), { once: true });
|
|
247
|
+
tablist.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
248
|
+
expect(tabs[2].getAttribute('aria-selected')).toBe('false');
|
|
249
|
+
expect(tabs[1].getAttribute('aria-selected')).toBe('true');
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Resizable Behavior
|
|
2
|
+
* Resizable Behavior
|
|
3
3
|
*
|
|
4
|
-
* Makes elements resizable by dragging
|
|
5
|
-
*
|
|
4
|
+
* Makes elements resizable by dragging. Compiled from its hyperscript `source`
|
|
5
|
+
* (single source of truth) — no imperative installer. The source measures the
|
|
6
|
+
* start size, runs a `repeat until event pointerup` loop, clamps to
|
|
7
|
+
* min/max dimensions, and writes `*width` / `*height` inline styles.
|
|
6
8
|
*
|
|
7
9
|
* @example
|
|
8
10
|
* ```html
|
|
@@ -21,56 +23,8 @@ export const resizableSource = resizableSchema.source;
|
|
|
21
23
|
export const resizableMetadata = resizableSchema;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
function installResizable(element: HTMLElement, params: Record<string, any>): void {
|
|
27
|
-
// Resolve handle element
|
|
28
|
-
let handleEl: HTMLElement = element;
|
|
29
|
-
if (params.handle && params.handle !== 'me') {
|
|
30
|
-
if (typeof params.handle === 'string') {
|
|
31
|
-
const found = element.querySelector(params.handle);
|
|
32
|
-
if (found instanceof HTMLElement) handleEl = found;
|
|
33
|
-
} else if (params.handle instanceof HTMLElement) {
|
|
34
|
-
handleEl = params.handle;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const minW = params.minWidth ?? 50;
|
|
39
|
-
const minH = params.minHeight ?? 50;
|
|
40
|
-
const maxW = params.maxWidth ?? 9999;
|
|
41
|
-
const maxH = params.maxHeight ?? 9999;
|
|
42
|
-
|
|
43
|
-
handleEl.addEventListener('pointerdown', (e: PointerEvent) => {
|
|
44
|
-
e.preventDefault();
|
|
45
|
-
element.dispatchEvent(new CustomEvent('resizable:start', { bubbles: true }));
|
|
46
|
-
|
|
47
|
-
const startWidth = element.offsetWidth;
|
|
48
|
-
const startHeight = element.offsetHeight;
|
|
49
|
-
const startX = e.clientX;
|
|
50
|
-
const startY = e.clientY;
|
|
51
|
-
|
|
52
|
-
function onMove(ev: PointerEvent) {
|
|
53
|
-
const newWidth = Math.max(minW, Math.min(maxW, startWidth + (ev.clientX - startX)));
|
|
54
|
-
const newHeight = Math.max(minH, Math.min(maxH, startHeight + (ev.clientY - startY)));
|
|
55
|
-
|
|
56
|
-
element.style.width = `${newWidth}px`;
|
|
57
|
-
element.style.height = `${newHeight}px`;
|
|
58
|
-
element.dispatchEvent(new CustomEvent('resizable:resize', { bubbles: true }));
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function onUp() {
|
|
62
|
-
document.removeEventListener('pointermove', onMove);
|
|
63
|
-
document.removeEventListener('pointerup', onUp);
|
|
64
|
-
element.dispatchEvent(new CustomEvent('resizable:end', { bubbles: true }));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
document.addEventListener('pointermove', onMove);
|
|
68
|
-
document.addEventListener('pointerup', onUp);
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Register the Resizable behavior with LokaScript.
|
|
26
|
+
* Register the Resizable behavior with LokaScript by compiling its hyperscript
|
|
27
|
+
* source and executing the resulting behavior definition.
|
|
74
28
|
*/
|
|
75
29
|
export async function registerResizable(hyperfixi?: LokaScriptInstance): Promise<void> {
|
|
76
30
|
const hf = hyperfixi || resolveRuntime();
|
|
@@ -81,15 +35,14 @@ export async function registerResizable(hyperfixi?: LokaScriptInstance): Promise
|
|
|
81
35
|
);
|
|
82
36
|
}
|
|
83
37
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
};
|
|
38
|
+
const result = hf.compileSync(resizableSchema.source, { traditional: true });
|
|
39
|
+
|
|
40
|
+
if (!result.ok) {
|
|
41
|
+
throw new Error(`Failed to compile Resizable behavior: ${JSON.stringify(result.errors)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
91
44
|
const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
|
|
92
|
-
await hf.execute(
|
|
45
|
+
await hf.execute(result.ast, ctx);
|
|
93
46
|
}
|
|
94
47
|
|
|
95
48
|
// Auto-register when loaded as a script tag
|
|
@@ -2,27 +2,35 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
describe('ScrollReveal behavior', () => {
|
|
4
4
|
describe('registerScrollReveal', () => {
|
|
5
|
-
it('should
|
|
6
|
-
const { registerScrollReveal } = await import('./scrollreveal');
|
|
5
|
+
it('should compile its hyperscript source and execute', async () => {
|
|
6
|
+
const { registerScrollReveal, scrollRevealSource } = await import('./scrollreveal');
|
|
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 registerScrollReveal(mock);
|
|
14
14
|
|
|
15
|
-
expect(mock.compileSync).
|
|
15
|
+
expect(mock.compileSync).toHaveBeenCalledWith(scrollRevealSource, { traditional: true });
|
|
16
16
|
expect(mock.execute).toHaveBeenCalledWith(
|
|
17
|
-
|
|
18
|
-
type: 'behavior',
|
|
19
|
-
name: 'ScrollReveal',
|
|
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 { registerScrollReveal } = await import('./scrollreveal');
|
|
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(registerScrollReveal(mock)).rejects.toThrowError(
|
|
30
|
+
/Failed to compile ScrollReveal/
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
26
34
|
it('should throw when no runtime available', async () => {
|
|
27
35
|
const { registerScrollReveal } = await import('./scrollreveal');
|
|
28
36
|
await expect(registerScrollReveal(undefined)).rejects.toThrowError(/LokaScript not found/);
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ScrollReveal Behavior
|
|
2
|
+
* ScrollReveal Behavior
|
|
3
3
|
*
|
|
4
4
|
* Adds a class or fires events when the element enters or exits the viewport.
|
|
5
|
-
* Uses the IntersectionObserver web standard API
|
|
5
|
+
* Uses the IntersectionObserver web standard API (in its hyperscript `source`'s
|
|
6
|
+
* `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 ScrollReveal 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 { scrollRevealSchema } from '../schemas/scrollreveal.schema';
|
|
@@ -14,37 +20,8 @@ export const scrollRevealSource = scrollRevealSchema.source;
|
|
|
14
20
|
export const scrollRevealMetadata = scrollRevealSchema;
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
|
-
*
|
|
18
|
-
|
|
19
|
-
function installScrollReveal(element: HTMLElement, params: Record<string, any>): void {
|
|
20
|
-
const cls = typeof params.cls === 'string' ? params.cls : 'revealed';
|
|
21
|
-
const threshold = typeof params.threshold === 'number' ? params.threshold : 0.1;
|
|
22
|
-
const once = params.once !== false;
|
|
23
|
-
const rootMargin = typeof params.rootMargin === 'string' ? params.rootMargin : '0px';
|
|
24
|
-
|
|
25
|
-
const observer = new IntersectionObserver(
|
|
26
|
-
entries => {
|
|
27
|
-
for (const entry of entries) {
|
|
28
|
-
if (entry.isIntersecting) {
|
|
29
|
-
element.classList.add(cls);
|
|
30
|
-
element.dispatchEvent(new CustomEvent('scrollreveal:enter', { bubbles: true }));
|
|
31
|
-
if (once) {
|
|
32
|
-
observer.disconnect();
|
|
33
|
-
}
|
|
34
|
-
} else if (!once) {
|
|
35
|
-
element.classList.remove(cls);
|
|
36
|
-
element.dispatchEvent(new CustomEvent('scrollreveal:exit', { bubbles: true }));
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
|
-
{ threshold, rootMargin }
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
observer.observe(element);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Register the ScrollReveal behavior with LokaScript.
|
|
23
|
+
* Register the ScrollReveal behavior with LokaScript by compiling its
|
|
24
|
+
* hyperscript source and executing the resulting behavior definition.
|
|
48
25
|
*/
|
|
49
26
|
export async function registerScrollReveal(hyperfixi?: LokaScriptInstance): Promise<void> {
|
|
50
27
|
const hf = hyperfixi || resolveRuntime();
|
|
@@ -55,15 +32,14 @@ export async function registerScrollReveal(hyperfixi?: LokaScriptInstance): Prom
|
|
|
55
32
|
);
|
|
56
33
|
}
|
|
57
34
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
};
|
|
35
|
+
const result = hf.compileSync(scrollRevealSchema.source, { traditional: true });
|
|
36
|
+
|
|
37
|
+
if (!result.ok) {
|
|
38
|
+
throw new Error(`Failed to compile ScrollReveal behavior: ${JSON.stringify(result.errors)}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
65
41
|
const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
|
|
66
|
-
await hf.execute(
|
|
42
|
+
await hf.execute(result.ast, ctx);
|
|
67
43
|
}
|
|
68
44
|
|
|
69
45
|
// Auto-register when loaded as a script tag
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sortable Behavior
|
|
2
|
+
* Sortable Behavior
|
|
3
3
|
*
|
|
4
|
-
* Drag-and-drop reordering of list items.
|
|
5
|
-
*
|
|
4
|
+
* Drag-and-drop reordering of list items. Apply to a container element; its
|
|
5
|
+
* `<li>` children become sortable. Compiled from its hyperscript `source` (single
|
|
6
|
+
* source of truth) — no imperative installer.
|
|
6
7
|
*
|
|
7
|
-
* Note: This behavior fires lifecycle events but does NOT automatically
|
|
8
|
-
*
|
|
9
|
-
* `sortable:move` event handlers.
|
|
8
|
+
* Note: This behavior fires lifecycle events but does NOT automatically reorder
|
|
9
|
+
* DOM elements. Users handle actual reordering in their `sortable:move` handlers.
|
|
10
10
|
*
|
|
11
11
|
* @example
|
|
12
12
|
* ```html
|
|
@@ -26,50 +26,8 @@ export const sortableSource = sortableSchema.source;
|
|
|
26
26
|
export const sortableMetadata = sortableSchema;
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
function installSortable(element: HTMLElement, params: Record<string, any>): void {
|
|
32
|
-
const dragClass = params.dragClass || 'sorting';
|
|
33
|
-
const handleSelector = params.handle || null;
|
|
34
|
-
|
|
35
|
-
element.addEventListener('pointerdown', (e: PointerEvent) => {
|
|
36
|
-
const target = e.target as HTMLElement;
|
|
37
|
-
const item = target.closest('li');
|
|
38
|
-
if (!item || !element.contains(item)) return;
|
|
39
|
-
|
|
40
|
-
// If a handle selector is specified, only start drag from the handle
|
|
41
|
-
if (handleSelector) {
|
|
42
|
-
const handleEl = target.closest(handleSelector);
|
|
43
|
-
if (!handleEl) return;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
e.preventDefault();
|
|
47
|
-
item.classList.add(dragClass);
|
|
48
|
-
element.dispatchEvent(new CustomEvent('sortable:start', { bubbles: true, detail: { item } }));
|
|
49
|
-
|
|
50
|
-
function onMove(ev: PointerEvent) {
|
|
51
|
-
element.dispatchEvent(
|
|
52
|
-
new CustomEvent('sortable:move', {
|
|
53
|
-
bubbles: true,
|
|
54
|
-
detail: { clientY: ev.clientY, item },
|
|
55
|
-
})
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function onUp() {
|
|
60
|
-
document.removeEventListener('pointermove', onMove);
|
|
61
|
-
document.removeEventListener('pointerup', onUp);
|
|
62
|
-
item!.classList.remove(dragClass);
|
|
63
|
-
element.dispatchEvent(new CustomEvent('sortable:end', { bubbles: true, detail: { item } }));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
document.addEventListener('pointermove', onMove);
|
|
67
|
-
document.addEventListener('pointerup', onUp);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Register the Sortable behavior with LokaScript.
|
|
29
|
+
* Register the Sortable behavior with LokaScript by compiling its hyperscript
|
|
30
|
+
* source and executing the resulting behavior definition.
|
|
73
31
|
*/
|
|
74
32
|
export async function registerSortable(hyperfixi?: LokaScriptInstance): Promise<void> {
|
|
75
33
|
const hf = hyperfixi || resolveRuntime();
|
|
@@ -80,15 +38,14 @@ export async function registerSortable(hyperfixi?: LokaScriptInstance): Promise<
|
|
|
80
38
|
);
|
|
81
39
|
}
|
|
82
40
|
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
};
|
|
41
|
+
const result = hf.compileSync(sortableSchema.source, { traditional: true });
|
|
42
|
+
|
|
43
|
+
if (!result.ok) {
|
|
44
|
+
throw new Error(`Failed to compile Sortable behavior: ${JSON.stringify(result.errors)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
90
47
|
const ctx = hf.createContext ? hf.createContext() : { locals: new Map(), globals: new Map() };
|
|
91
|
-
await hf.execute(
|
|
48
|
+
await hf.execute(result.ast, ctx);
|
|
92
49
|
}
|
|
93
50
|
|
|
94
51
|
// Auto-register when loaded as a script tag
|
|
@@ -2,27 +2,33 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
describe('Tabs behavior', () => {
|
|
4
4
|
describe('registerTabs', () => {
|
|
5
|
-
it('should
|
|
6
|
-
const { registerTabs } = await import('./tabs');
|
|
5
|
+
it('should compile its hyperscript source and execute', async () => {
|
|
6
|
+
const { registerTabs, tabsSource } = await import('./tabs');
|
|
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 registerTabs(mock);
|
|
14
14
|
|
|
15
|
-
expect(mock.compileSync).
|
|
15
|
+
expect(mock.compileSync).toHaveBeenCalledWith(tabsSource, { traditional: true });
|
|
16
16
|
expect(mock.execute).toHaveBeenCalledWith(
|
|
17
|
-
|
|
18
|
-
type: 'behavior',
|
|
19
|
-
name: 'Tabs',
|
|
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 { registerTabs } = await import('./tabs');
|
|
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(registerTabs(mock)).rejects.toThrowError(/Failed to compile Tabs/);
|
|
30
|
+
});
|
|
31
|
+
|
|
26
32
|
it('should throw when no runtime available', async () => {
|
|
27
33
|
const { registerTabs } = await import('./tabs');
|
|
28
34
|
await expect(registerTabs(undefined)).rejects.toThrowError(/LokaScript not found/);
|
|
@@ -31,7 +37,7 @@ describe('Tabs behavior', () => {
|
|
|
31
37
|
it('should fall back to manual context when createContext is missing', async () => {
|
|
32
38
|
const { registerTabs } = await import('./tabs');
|
|
33
39
|
const mock = {
|
|
34
|
-
compileSync: vi.fn(),
|
|
40
|
+
compileSync: vi.fn().mockReturnValue({ ok: true, ast: { type: 'behavior' } }),
|
|
35
41
|
execute: vi.fn().mockResolvedValue(undefined),
|
|
36
42
|
};
|
|
37
43
|
|