@vettvangur/framework 0.0.1
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/browser-events.d.ts +39 -0
- package/dist/browser-events.js +41 -0
- package/dist/dom.d.ts +34 -0
- package/dist/dom.js +37 -0
- package/dist/events.d.ts +87 -0
- package/dist/events.js +77 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/preload-timeout.d.ts +16 -0
- package/dist/preload-timeout.js +23 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/dist/vettvangur.d.ts +45 -0
- package/dist/vettvangur.js +187 -0
- package/package.json +25 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback when the DOM is fully parsed (no need to wait for images/CSS).
|
|
3
|
+
*
|
|
4
|
+
* - If the document is still `loading`, the callback is attached to the
|
|
5
|
+
* `DOMContentLoaded` event and will run once.
|
|
6
|
+
* - If parsing is already complete, the callback runs immediately.
|
|
7
|
+
*
|
|
8
|
+
* @param fn - Function to invoke when the DOM is ready.
|
|
9
|
+
* @example
|
|
10
|
+
* onDOMContentLoaded(() => {
|
|
11
|
+
* initApp()
|
|
12
|
+
* })
|
|
13
|
+
*/
|
|
14
|
+
export declare const onDOMContentLoaded: (fn: () => void) => void;
|
|
15
|
+
/**
|
|
16
|
+
* Subscribe to browser history changes triggered by `history.pushState`,
|
|
17
|
+
* `history.replaceState`, or user navigation (back/forward).
|
|
18
|
+
*
|
|
19
|
+
* @param fn - Handler invoked with the `PopStateEvent`.
|
|
20
|
+
* @example
|
|
21
|
+
* onPopState((e) => {
|
|
22
|
+
* console.debug('popstate', e.state)
|
|
23
|
+
* router.navigate(location.pathname + location.search)
|
|
24
|
+
* })
|
|
25
|
+
*/
|
|
26
|
+
export declare const onPopState: (fn: (e: PopStateEvent) => void) => void;
|
|
27
|
+
/**
|
|
28
|
+
* Listen for document visibility changes (e.g., tab hidden/visible switches).
|
|
29
|
+
*
|
|
30
|
+
* Useful for pausing timers, videos, or analytics when the page is hidden.
|
|
31
|
+
*
|
|
32
|
+
* @param fn - Callback invoked on every `visibilitychange`.
|
|
33
|
+
* @example
|
|
34
|
+
* onVisibilityChange(() => {
|
|
35
|
+
* if (document.hidden) pause()
|
|
36
|
+
* else resume()
|
|
37
|
+
* })
|
|
38
|
+
*/
|
|
39
|
+
export declare const onVisibilityChange: (fn: () => void) => void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run a callback when the DOM is fully parsed (no need to wait for images/CSS).
|
|
3
|
+
*
|
|
4
|
+
* - If the document is still `loading`, the callback is attached to the
|
|
5
|
+
* `DOMContentLoaded` event and will run once.
|
|
6
|
+
* - If parsing is already complete, the callback runs immediately.
|
|
7
|
+
*
|
|
8
|
+
* @param fn - Function to invoke when the DOM is ready.
|
|
9
|
+
* @example
|
|
10
|
+
* onDOMContentLoaded(() => {
|
|
11
|
+
* initApp()
|
|
12
|
+
* })
|
|
13
|
+
*/
|
|
14
|
+
export const onDOMContentLoaded = (fn) => document.readyState === 'loading'
|
|
15
|
+
? document.addEventListener('DOMContentLoaded', fn, { once: true })
|
|
16
|
+
: fn();
|
|
17
|
+
/**
|
|
18
|
+
* Subscribe to browser history changes triggered by `history.pushState`,
|
|
19
|
+
* `history.replaceState`, or user navigation (back/forward).
|
|
20
|
+
*
|
|
21
|
+
* @param fn - Handler invoked with the `PopStateEvent`.
|
|
22
|
+
* @example
|
|
23
|
+
* onPopState((e) => {
|
|
24
|
+
* console.debug('popstate', e.state)
|
|
25
|
+
* router.navigate(location.pathname + location.search)
|
|
26
|
+
* })
|
|
27
|
+
*/
|
|
28
|
+
export const onPopState = (fn) => window.addEventListener('popstate', fn);
|
|
29
|
+
/**
|
|
30
|
+
* Listen for document visibility changes (e.g., tab hidden/visible switches).
|
|
31
|
+
*
|
|
32
|
+
* Useful for pausing timers, videos, or analytics when the page is hidden.
|
|
33
|
+
*
|
|
34
|
+
* @param fn - Callback invoked on every `visibilitychange`.
|
|
35
|
+
* @example
|
|
36
|
+
* onVisibilityChange(() => {
|
|
37
|
+
* if (document.hidden) pause()
|
|
38
|
+
* else resume()
|
|
39
|
+
* })
|
|
40
|
+
*/
|
|
41
|
+
export const onVisibilityChange = (fn) => document.addEventListener('visibilitychange', fn);
|
package/dist/dom.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query all elements matching a selector within a given root and return them as a plain array.
|
|
3
|
+
*
|
|
4
|
+
* - Generic `T` narrows the element type of the result (defaults to `HTMLElement`).
|
|
5
|
+
* - Thin wrapper over `root.querySelectorAll` + `Array.from`.
|
|
6
|
+
*
|
|
7
|
+
* @typeParam T - Element subtype expected from the selector.
|
|
8
|
+
* @param root - Search root (e.g., `document`, an `HTMLElement`, or a `ShadowRoot`).
|
|
9
|
+
* @param s - CSS selector string.
|
|
10
|
+
* @returns An array of matching elements typed as `T[]`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const items = $$<HTMLLIElement>(document, 'ul.menu > li')
|
|
14
|
+
* items.forEach((li) => li.classList.add('is-found'))
|
|
15
|
+
*/
|
|
16
|
+
export declare const $$: <T extends Element = HTMLElement>(root: ParentNode, s: string) => T[];
|
|
17
|
+
/**
|
|
18
|
+
* Check if an element is currently in (any part of) the viewport.
|
|
19
|
+
*
|
|
20
|
+
* The element is considered "in view" if its bounding box intersects the viewport:
|
|
21
|
+
* `top < window.innerHeight` **and** `bottom > 0`.
|
|
22
|
+
*
|
|
23
|
+
* Note: For preloading just-before-visible content, prefer `IntersectionObserver`
|
|
24
|
+
* with a positive `rootMargin`.
|
|
25
|
+
*
|
|
26
|
+
* @param el - The element to test.
|
|
27
|
+
* @returns `true` if any portion of the element is visible, otherwise `false`.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* if (inViewport(hero)) {
|
|
31
|
+
* hydrateHero()
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
export declare const inViewport: (el: Element) => boolean;
|
package/dist/dom.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query all elements matching a selector within a given root and return them as a plain array.
|
|
3
|
+
*
|
|
4
|
+
* - Generic `T` narrows the element type of the result (defaults to `HTMLElement`).
|
|
5
|
+
* - Thin wrapper over `root.querySelectorAll` + `Array.from`.
|
|
6
|
+
*
|
|
7
|
+
* @typeParam T - Element subtype expected from the selector.
|
|
8
|
+
* @param root - Search root (e.g., `document`, an `HTMLElement`, or a `ShadowRoot`).
|
|
9
|
+
* @param s - CSS selector string.
|
|
10
|
+
* @returns An array of matching elements typed as `T[]`.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const items = $$<HTMLLIElement>(document, 'ul.menu > li')
|
|
14
|
+
* items.forEach((li) => li.classList.add('is-found'))
|
|
15
|
+
*/
|
|
16
|
+
export const $$ = (root, s) => Array.from(root.querySelectorAll(s));
|
|
17
|
+
/**
|
|
18
|
+
* Check if an element is currently in (any part of) the viewport.
|
|
19
|
+
*
|
|
20
|
+
* The element is considered "in view" if its bounding box intersects the viewport:
|
|
21
|
+
* `top < window.innerHeight` **and** `bottom > 0`.
|
|
22
|
+
*
|
|
23
|
+
* Note: For preloading just-before-visible content, prefer `IntersectionObserver`
|
|
24
|
+
* with a positive `rootMargin`.
|
|
25
|
+
*
|
|
26
|
+
* @param el - The element to test.
|
|
27
|
+
* @returns `true` if any portion of the element is visible, otherwise `false`.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* if (inViewport(hero)) {
|
|
31
|
+
* hydrateHero()
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
export const inViewport = (el) => {
|
|
35
|
+
const r = el.getBoundingClientRect();
|
|
36
|
+
return r.top < window.innerHeight && r.bottom > 0;
|
|
37
|
+
};
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Handler } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Create a tiny event bus backed by `EventTarget`.
|
|
4
|
+
*
|
|
5
|
+
* Features:
|
|
6
|
+
* - `on(type, handler)` → subscribe; returns an unsubscribe function.
|
|
7
|
+
* - `once(type, handler)` → subscribe for a single invocation.
|
|
8
|
+
* - `emit(type, payload)` → publish with an optional payload.
|
|
9
|
+
*
|
|
10
|
+
* Example:
|
|
11
|
+
* ```ts
|
|
12
|
+
* const bus = createEventBus()
|
|
13
|
+
*
|
|
14
|
+
* const off = bus.on('OPEN_FLYOUT', (detail) => {
|
|
15
|
+
* console.log('opened from', detail?.source)
|
|
16
|
+
* })
|
|
17
|
+
*
|
|
18
|
+
* bus.emit('OPEN_FLYOUT', { source: 'button' })
|
|
19
|
+
* off() // unsubscribe
|
|
20
|
+
*
|
|
21
|
+
* bus.once('READY', () => console.log('ready once'))
|
|
22
|
+
* bus.emit('READY')
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* @returns {{
|
|
26
|
+
* on(type: string, handler: Handler): () => void;
|
|
27
|
+
* once(type: string, handler: Handler): void;
|
|
28
|
+
* emit(type: string, payload?: any): void;
|
|
29
|
+
* }}
|
|
30
|
+
* An object with `on`, `once`, and `emit` methods.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createEventBus(): {
|
|
33
|
+
/**
|
|
34
|
+
* Subscribe to an event type.
|
|
35
|
+
* @param {string} type - Event name.
|
|
36
|
+
* @param {Handler} handler - Called with `payload` passed to {@link emit}.
|
|
37
|
+
* @returns {() => void} Unsubscribe function to remove this handler.
|
|
38
|
+
*/
|
|
39
|
+
on(type: string, handler: Handler): () => void;
|
|
40
|
+
/**
|
|
41
|
+
* Subscribe to a single occurrence of an event type.
|
|
42
|
+
* Automatically unsubscribes after the first call.
|
|
43
|
+
* @param {string} type - Event name.
|
|
44
|
+
* @param {Handler} handler - Called once with the emitted `payload`.
|
|
45
|
+
*/
|
|
46
|
+
once(type: string, handler: Handler): void;
|
|
47
|
+
/**
|
|
48
|
+
* Publish an event with an optional payload.
|
|
49
|
+
* @param {string} type - Event name.
|
|
50
|
+
* @param {any} [payload] - Arbitrary data exposed to subscribers as `detail`.
|
|
51
|
+
*/
|
|
52
|
+
emit(type: string, payload?: any): void;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A shared, app-wide event bus instance.
|
|
56
|
+
*
|
|
57
|
+
* Example:
|
|
58
|
+
* ```ts
|
|
59
|
+
* // listen somewhere
|
|
60
|
+
* const off = events.on('TOGGLE_SIDEBAR', () => {...})
|
|
61
|
+
*
|
|
62
|
+
* // emit elsewhere
|
|
63
|
+
* events.emit('TOGGLE_SIDEBAR')
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
export declare const events: {
|
|
67
|
+
/**
|
|
68
|
+
* Subscribe to an event type.
|
|
69
|
+
* @param {string} type - Event name.
|
|
70
|
+
* @param {Handler} handler - Called with `payload` passed to {@link emit}.
|
|
71
|
+
* @returns {() => void} Unsubscribe function to remove this handler.
|
|
72
|
+
*/
|
|
73
|
+
on(type: string, handler: Handler): () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Subscribe to a single occurrence of an event type.
|
|
76
|
+
* Automatically unsubscribes after the first call.
|
|
77
|
+
* @param {string} type - Event name.
|
|
78
|
+
* @param {Handler} handler - Called once with the emitted `payload`.
|
|
79
|
+
*/
|
|
80
|
+
once(type: string, handler: Handler): void;
|
|
81
|
+
/**
|
|
82
|
+
* Publish an event with an optional payload.
|
|
83
|
+
* @param {string} type - Event name.
|
|
84
|
+
* @param {any} [payload] - Arbitrary data exposed to subscribers as `detail`.
|
|
85
|
+
*/
|
|
86
|
+
emit(type: string, payload?: any): void;
|
|
87
|
+
};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a tiny event bus backed by `EventTarget`.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - `on(type, handler)` → subscribe; returns an unsubscribe function.
|
|
6
|
+
* - `once(type, handler)` → subscribe for a single invocation.
|
|
7
|
+
* - `emit(type, payload)` → publish with an optional payload.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* ```ts
|
|
11
|
+
* const bus = createEventBus()
|
|
12
|
+
*
|
|
13
|
+
* const off = bus.on('OPEN_FLYOUT', (detail) => {
|
|
14
|
+
* console.log('opened from', detail?.source)
|
|
15
|
+
* })
|
|
16
|
+
*
|
|
17
|
+
* bus.emit('OPEN_FLYOUT', { source: 'button' })
|
|
18
|
+
* off() // unsubscribe
|
|
19
|
+
*
|
|
20
|
+
* bus.once('READY', () => console.log('ready once'))
|
|
21
|
+
* bus.emit('READY')
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @returns {{
|
|
25
|
+
* on(type: string, handler: Handler): () => void;
|
|
26
|
+
* once(type: string, handler: Handler): void;
|
|
27
|
+
* emit(type: string, payload?: any): void;
|
|
28
|
+
* }}
|
|
29
|
+
* An object with `on`, `once`, and `emit` methods.
|
|
30
|
+
*/
|
|
31
|
+
export function createEventBus() {
|
|
32
|
+
const target = new EventTarget();
|
|
33
|
+
return {
|
|
34
|
+
/**
|
|
35
|
+
* Subscribe to an event type.
|
|
36
|
+
* @param {string} type - Event name.
|
|
37
|
+
* @param {Handler} handler - Called with `payload` passed to {@link emit}.
|
|
38
|
+
* @returns {() => void} Unsubscribe function to remove this handler.
|
|
39
|
+
*/
|
|
40
|
+
on(type, handler) {
|
|
41
|
+
const wrapped = (e) => handler(e.detail);
|
|
42
|
+
target.addEventListener(type, wrapped);
|
|
43
|
+
return () => target.removeEventListener(type, wrapped); // unsubscribe
|
|
44
|
+
},
|
|
45
|
+
/**
|
|
46
|
+
* Subscribe to a single occurrence of an event type.
|
|
47
|
+
* Automatically unsubscribes after the first call.
|
|
48
|
+
* @param {string} type - Event name.
|
|
49
|
+
* @param {Handler} handler - Called once with the emitted `payload`.
|
|
50
|
+
*/
|
|
51
|
+
once(type, handler) {
|
|
52
|
+
const wrapped = (e) => handler(e.detail);
|
|
53
|
+
target.addEventListener(type, wrapped, { once: true });
|
|
54
|
+
},
|
|
55
|
+
/**
|
|
56
|
+
* Publish an event with an optional payload.
|
|
57
|
+
* @param {string} type - Event name.
|
|
58
|
+
* @param {any} [payload] - Arbitrary data exposed to subscribers as `detail`.
|
|
59
|
+
*/
|
|
60
|
+
emit(type, payload) {
|
|
61
|
+
target.dispatchEvent(new CustomEvent(type, { detail: payload }));
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* A shared, app-wide event bus instance.
|
|
67
|
+
*
|
|
68
|
+
* Example:
|
|
69
|
+
* ```ts
|
|
70
|
+
* // listen somewhere
|
|
71
|
+
* const off = events.on('TOGGLE_SIDEBAR', () => {...})
|
|
72
|
+
*
|
|
73
|
+
* // emit elsewhere
|
|
74
|
+
* events.emit('TOGGLE_SIDEBAR')
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
export const events = createEventBus();
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply staged preload classes to the document body to prevent e.g. transitions from running and font swapping to be visible.
|
|
3
|
+
*
|
|
4
|
+
* Sequence:
|
|
5
|
+
* - After ~100 ms: adds `loaded` (useful to start lightweight effects after first paint).
|
|
6
|
+
* - After ~400 ms: adds `preload--hidden` and removes `preload--transitions`
|
|
7
|
+
* to fully hide any preload UI and enable normal transitions.
|
|
8
|
+
*
|
|
9
|
+
* Pair this with CSS that initially disables transitions/animations under
|
|
10
|
+
* `.preload--transitions` and displays your preload UI until `preload--hidden` is set.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Run once DOM is ready
|
|
14
|
+
* window.addEventListener('DOMContentLoaded', preloadTimeout)
|
|
15
|
+
*/
|
|
16
|
+
export declare function preloadTimeout(): void;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply staged preload classes to the document body to prevent e.g. transitions from running and font swapping to be visible.
|
|
3
|
+
*
|
|
4
|
+
* Sequence:
|
|
5
|
+
* - After ~100 ms: adds `loaded` (useful to start lightweight effects after first paint).
|
|
6
|
+
* - After ~400 ms: adds `preload--hidden` and removes `preload--transitions`
|
|
7
|
+
* to fully hide any preload UI and enable normal transitions.
|
|
8
|
+
*
|
|
9
|
+
* Pair this with CSS that initially disables transitions/animations under
|
|
10
|
+
* `.preload--transitions` and displays your preload UI until `preload--hidden` is set.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* // Run once DOM is ready
|
|
14
|
+
* window.addEventListener('DOMContentLoaded', preloadTimeout)
|
|
15
|
+
*/
|
|
16
|
+
export function preloadTimeout() {
|
|
17
|
+
const body = document.body;
|
|
18
|
+
setTimeout(() => body.classList.add('loaded'), 100);
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
body.classList.add('preload--hidden');
|
|
21
|
+
body.classList.remove('preload--transitions');
|
|
22
|
+
}, 400);
|
|
23
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type FeatureProps = Record<string, unknown>;
|
|
2
|
+
export interface FeatureInstance {
|
|
3
|
+
mount(): void;
|
|
4
|
+
unmount(): void;
|
|
5
|
+
}
|
|
6
|
+
export type Feature = (root: HTMLElement, props?: FeatureProps) => FeatureInstance;
|
|
7
|
+
export type Loader = () => Promise<any>;
|
|
8
|
+
export type Handler = (payload?: any) => void;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A function that dynamically imports a feature module.
|
|
3
|
+
* It should usually be of the form: `() => import('./feature')`.
|
|
4
|
+
*/
|
|
5
|
+
export type Loader = () => Promise<any>;
|
|
6
|
+
/**
|
|
7
|
+
* A mapping from a comma-separated list of CSS selectors to a dynamic loader.
|
|
8
|
+
*
|
|
9
|
+
* Example:
|
|
10
|
+
* ```ts
|
|
11
|
+
* const moduleMap: ModuleMap = {
|
|
12
|
+
* '.accordion, [data-feature="accordion"]': () => import('./features/accordion'),
|
|
13
|
+
* '.tabs': () => import('./features/tabs'),
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export type ModuleMap = Record<string, Loader>;
|
|
18
|
+
/**
|
|
19
|
+
* Bootstraps feature mounting for a subtree.
|
|
20
|
+
*
|
|
21
|
+
* Responsibilities:
|
|
22
|
+
* - Eagerly mounts elements already in (or near) the viewport.
|
|
23
|
+
* - Lazily mounts elements as they enter the viewport using `IntersectionObserver`.
|
|
24
|
+
* - Observes the DOM with `MutationObserver` to mount/unmount nodes that are added/removed.
|
|
25
|
+
* - Ensures each element matching a selector in `moduleMap` is mounted at most once.
|
|
26
|
+
*
|
|
27
|
+
* Notes:
|
|
28
|
+
* - Uses a `rootMargin` of `200px` to pre-mount just before elements scroll into view.
|
|
29
|
+
* - Stores the `FeatureInstance` on each element under a private key to support unmount.
|
|
30
|
+
* - If a node (or any descendant) is removed, its feature is unmounted automatically.
|
|
31
|
+
*
|
|
32
|
+
* @param root - The search root (defaults to `document`). Typically a document or shadow root.
|
|
33
|
+
* @param moduleMap - A map of CSS selector list → dynamic import loader.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { vettvangur } from '@vettvangur/framework'
|
|
38
|
+
*
|
|
39
|
+
* vettvangur(document, {
|
|
40
|
+
* '.accordion, [data-accordion]': () => import('./features/accordion'),
|
|
41
|
+
* '.tabs': () => import('./features/tabs'),
|
|
42
|
+
* })
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function vettvangur(root: ParentNode, moduleMap: ModuleMap): void;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { $$, inViewport } from './dom';
|
|
2
|
+
/** @internal Symbolic key stored on mounted HTMLElements to cache their FeatureInstance. */
|
|
3
|
+
const INST = '__feature_instance__';
|
|
4
|
+
/**
|
|
5
|
+
* Cache for dynamic imports so multiple elements using the same loader
|
|
6
|
+
* do not trigger duplicate network requests.
|
|
7
|
+
*/
|
|
8
|
+
const loaderCache = new WeakMap();
|
|
9
|
+
/**
|
|
10
|
+
* Mount a single element with the provided dynamic loader.
|
|
11
|
+
*
|
|
12
|
+
* - Loads the module (cached per `Loader`).
|
|
13
|
+
* - Resolves the module's default export (or the module itself) as a {@link Feature}.
|
|
14
|
+
* - Calls `feature(root).mount()` if present.
|
|
15
|
+
* - Stores the returned {@link FeatureInstance} on the element for later unmounts.
|
|
16
|
+
*
|
|
17
|
+
* @param el - The element to enhance with a feature.
|
|
18
|
+
* @param load - The dynamic import function that resolves to a module exporting a {@link Feature} as default (or the module itself is a function).
|
|
19
|
+
*/
|
|
20
|
+
async function mount(el, load) {
|
|
21
|
+
// Already mounted? bail.
|
|
22
|
+
if (el[INST])
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
// Reuse an in-flight or completed import for this loader.
|
|
26
|
+
const promise = loaderCache.get(load) ?? (loaderCache.set(load, load()), loaderCache.get(load));
|
|
27
|
+
const mod = await promise;
|
|
28
|
+
const exp = mod.default ?? mod;
|
|
29
|
+
let instance;
|
|
30
|
+
if (typeof exp === 'function') {
|
|
31
|
+
instance = exp(el);
|
|
32
|
+
instance?.mount?.();
|
|
33
|
+
}
|
|
34
|
+
if (instance) {
|
|
35
|
+
;
|
|
36
|
+
el[INST] = instance;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
console.error('Feature mount failed:', e);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Unmount a previously mounted element.
|
|
45
|
+
*
|
|
46
|
+
* - Invokes `FeatureInstance.unmount()` if present.
|
|
47
|
+
* - Clears the cached instance from the element.
|
|
48
|
+
*
|
|
49
|
+
* @param el - The enhanced element to unmount.
|
|
50
|
+
*/
|
|
51
|
+
function unmount(el) {
|
|
52
|
+
const inst = el[INST];
|
|
53
|
+
if (inst?.unmount) {
|
|
54
|
+
try {
|
|
55
|
+
inst.unmount();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// swallow unmount errors to avoid breaking DOM tear-down flows
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
;
|
|
62
|
+
el[INST] = undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the appropriate loader for a given element by checking all selector lists
|
|
66
|
+
* in the {@link ModuleMap}. Keys may contain multiple selectors separated by commas.
|
|
67
|
+
*
|
|
68
|
+
* @param el - The element to match against module map selectors.
|
|
69
|
+
* @param moduleMap - The selector → loader mapping.
|
|
70
|
+
* @returns The matching loader or `null` if no selector matches the element.
|
|
71
|
+
*/
|
|
72
|
+
function resolveLoader(el, moduleMap) {
|
|
73
|
+
for (const key of Object.keys(moduleMap)) {
|
|
74
|
+
const sels = key
|
|
75
|
+
.split(',')
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
if (sels.some((s) => el.matches(s))) {
|
|
79
|
+
return moduleMap[key];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Bootstraps feature mounting for a subtree.
|
|
86
|
+
*
|
|
87
|
+
* Responsibilities:
|
|
88
|
+
* - Eagerly mounts elements already in (or near) the viewport.
|
|
89
|
+
* - Lazily mounts elements as they enter the viewport using `IntersectionObserver`.
|
|
90
|
+
* - Observes the DOM with `MutationObserver` to mount/unmount nodes that are added/removed.
|
|
91
|
+
* - Ensures each element matching a selector in `moduleMap` is mounted at most once.
|
|
92
|
+
*
|
|
93
|
+
* Notes:
|
|
94
|
+
* - Uses a `rootMargin` of `200px` to pre-mount just before elements scroll into view.
|
|
95
|
+
* - Stores the `FeatureInstance` on each element under a private key to support unmount.
|
|
96
|
+
* - If a node (or any descendant) is removed, its feature is unmounted automatically.
|
|
97
|
+
*
|
|
98
|
+
* @param root - The search root (defaults to `document`). Typically a document or shadow root.
|
|
99
|
+
* @param moduleMap - A map of CSS selector list → dynamic import loader.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* import { vettvangur } from '@vettvangur/framework'
|
|
104
|
+
*
|
|
105
|
+
* vettvangur(document, {
|
|
106
|
+
* '.accordion, [data-accordion]': () => import('./features/accordion'),
|
|
107
|
+
* '.tabs': () => import('./features/tabs'),
|
|
108
|
+
* })
|
|
109
|
+
* ```
|
|
110
|
+
*/
|
|
111
|
+
export function vettvangur(root = document, moduleMap) {
|
|
112
|
+
/** Lazily mount elements as they approach the viewport. */
|
|
113
|
+
const io = new IntersectionObserver((entries) => {
|
|
114
|
+
entries.forEach((e) => {
|
|
115
|
+
if (!e.isIntersecting)
|
|
116
|
+
return;
|
|
117
|
+
const el = e.target;
|
|
118
|
+
const loader = resolveLoader(el, moduleMap);
|
|
119
|
+
if (!loader)
|
|
120
|
+
return io.unobserve(el);
|
|
121
|
+
mount(el, loader).finally(() => io.unobserve(el));
|
|
122
|
+
});
|
|
123
|
+
}, { root: null, threshold: 0, rootMargin: '200px 0px' });
|
|
124
|
+
/** Prevent double-processing when multiple selectors overlap. */
|
|
125
|
+
const seen = new WeakSet();
|
|
126
|
+
// Initial scan for all configured selectors within the root subtree.
|
|
127
|
+
for (const key of Object.keys(moduleMap)) {
|
|
128
|
+
const sels = key
|
|
129
|
+
.split(',')
|
|
130
|
+
.map((s) => s.trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
sels.forEach((sel) => $$(root, sel).forEach((el) => {
|
|
133
|
+
if (seen.has(el))
|
|
134
|
+
return;
|
|
135
|
+
seen.add(el);
|
|
136
|
+
if (inViewport(el)) {
|
|
137
|
+
// Eager mount if already visible
|
|
138
|
+
mount(el, moduleMap[key]);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
// Otherwise, lazy mount as it comes into view
|
|
142
|
+
io.observe(el);
|
|
143
|
+
}
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Observe DOM mutations:
|
|
148
|
+
* - For added nodes: mount if the node itself (or descendants) match.
|
|
149
|
+
* - For removed nodes: unmount any feature instances attached to the node or its descendants.
|
|
150
|
+
*/
|
|
151
|
+
const mo = new MutationObserver((muts) => {
|
|
152
|
+
for (const m of muts) {
|
|
153
|
+
// Added nodes → try to mount
|
|
154
|
+
m.addedNodes.forEach((n) => {
|
|
155
|
+
if (!(n instanceof HTMLElement))
|
|
156
|
+
return;
|
|
157
|
+
// If the added node itself matches a selector
|
|
158
|
+
const loader = resolveLoader(n, moduleMap);
|
|
159
|
+
if (loader) {
|
|
160
|
+
inViewport(n) ? mount(n, loader) : io.observe(n);
|
|
161
|
+
}
|
|
162
|
+
// Also search descendants for matches
|
|
163
|
+
for (const key of Object.keys(moduleMap)) {
|
|
164
|
+
const sels = key
|
|
165
|
+
.split(',')
|
|
166
|
+
.map((s) => s.trim())
|
|
167
|
+
.filter(Boolean);
|
|
168
|
+
sels.forEach((sel) => n.querySelectorAll(sel).forEach((el) => {
|
|
169
|
+
if (el[INST])
|
|
170
|
+
return;
|
|
171
|
+
inViewport(el) ? mount(el, moduleMap[key]) : io.observe(el);
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
// Removed nodes → unmount if needed
|
|
176
|
+
m.removedNodes.forEach((n) => {
|
|
177
|
+
if (!(n instanceof HTMLElement))
|
|
178
|
+
return;
|
|
179
|
+
if (n[INST])
|
|
180
|
+
unmount(n);
|
|
181
|
+
n.querySelectorAll('*').forEach((el) => el[INST] && unmount(el));
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
// Observe the whole document body for dynamic changes.
|
|
186
|
+
mo.observe(document.body, { childList: true, subtree: true });
|
|
187
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vettvangur/framework",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*"
|
|
8
|
+
],
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.6.3",
|
|
18
|
+
"rimraf": "^5.0.0"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"clean": "rimraf dist || rm -rf dist",
|
|
22
|
+
"bundle": "pnpm clean && tsc -p tsconfig.json",
|
|
23
|
+
"dist": "pnpm publish"
|
|
24
|
+
}
|
|
25
|
+
}
|