@hubpav/hwio-web-runtime 0.8.0 → 0.9.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 +7 -2
- package/dist/count-core.d.ts +21 -0
- package/dist/count-core.js +30 -0
- package/dist/counter.d.ts +9 -2
- package/dist/counter.js +68 -4
- package/dist/dropdown.d.ts +12 -0
- package/dist/dropdown.js +70 -0
- package/dist/iso.d.ts +15 -0
- package/dist/iso.js +58 -0
- package/dist/nav.d.ts +4 -2
- package/dist/nav.js +39 -7
- package/dist/popup.d.ts +14 -0
- package/dist/popup.js +19 -0
- package/dist/reveal.d.ts +18 -1
- package/dist/reveal.js +32 -2
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
Framework-free browser behaviours for the HARDWARIO websites, one ES module per concern
|
|
4
4
|
(`/consent`, `/forms`, `/turnstile`, `/attribution`, `/theme`, `/track`, `/reveal`,
|
|
5
|
-
`/counter`, `/nav`, `/accordion`) plus `/inline` (static bootstrap scripts with their
|
|
5
|
+
`/counter`, `/nav`, `/dropdown`, `/iso`, `/accordion`) plus `/inline` (static bootstrap scripts with their
|
|
6
6
|
SHA-256 hashes for CSP). Every behaviour registers through `hwioMount()`, so it binds on
|
|
7
7
|
`astro:page-load` and cleans up on `astro:before-swap` when Astro's ClientRouter is present
|
|
8
8
|
and binds once otherwise. Pure logic lives in `*-core` modules and is unit-tested with
|
|
9
|
-
`node --test`.
|
|
9
|
+
`node --test`. `/popup` is the one-popup-at-a-time protocol (`hwio:popupopen`) through which
|
|
10
|
+
`/nav` (mega panels, drawer) and `/dropdown` (`details[data-hwio-dropdown]`) close each other.
|
|
11
|
+
|
|
12
|
+
Motion is fail-safe: `/reveal` hides content waiting for its reveal only after it has started
|
|
13
|
+
(class `hwio-reveal-on` on `<html>`), `/counter` and `/iso` change nothing until they run, and all
|
|
14
|
+
three show the final state at once under `prefers-reduced-motion`.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic of the count-up (counter.ts, `[data-hwio-count]`), unit-tested with `node --test`.
|
|
3
|
+
* The figure is rendered on the server in its final form ("7 000+", "Since 2016"); the runtime
|
|
4
|
+
* counts from `from` to `to` through `template` and ends on that exact server text.
|
|
5
|
+
*/
|
|
6
|
+
export interface HWioCountSpec {
|
|
7
|
+
to: number;
|
|
8
|
+
from: number;
|
|
9
|
+
/** Text around the number; `{n}` marks it ("{n}+", "Since {n}"). */
|
|
10
|
+
template: string;
|
|
11
|
+
/** Thousands separator ("" none, "\u00a0" a non-breaking space, "," ...). */
|
|
12
|
+
group: string;
|
|
13
|
+
}
|
|
14
|
+
/** Reads the data attributes of a count element (`data-hwio-count`, `-from`, `-template`, `-group`). */
|
|
15
|
+
export declare function hwioParseCount(data: Record<string, string | undefined>): HWioCountSpec | null;
|
|
16
|
+
/** Formats an integer with an optional thousands separator into the template. */
|
|
17
|
+
export declare function hwioFormatCount(n: number, template?: string, group?: string): string;
|
|
18
|
+
/** Ease-out cubic on 0..1: fast start, calm settle (the motion rule: ease-out, nothing bounces). */
|
|
19
|
+
export declare function hwioEaseOut(progress: number): number;
|
|
20
|
+
/** The number shown at `progress` (0..1) of the count. */
|
|
21
|
+
export declare function hwioCountAt(spec: Pick<HWioCountSpec, 'from' | 'to'>, progress: number): number;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Reads the data attributes of a count element (`data-hwio-count`, `-from`, `-template`, `-group`). */
|
|
2
|
+
export function hwioParseCount(data) {
|
|
3
|
+
const to = Number(data.hwioCount);
|
|
4
|
+
if (data.hwioCount === undefined || data.hwioCount === '' || !Number.isFinite(to))
|
|
5
|
+
return null;
|
|
6
|
+
const fromRaw = data.hwioCountFrom;
|
|
7
|
+
const from = fromRaw === undefined || fromRaw === '' ? 0 : Number(fromRaw);
|
|
8
|
+
return {
|
|
9
|
+
to,
|
|
10
|
+
from: Number.isFinite(from) ? from : 0,
|
|
11
|
+
template: data.hwioCountTemplate && data.hwioCountTemplate.includes('{n}') ? data.hwioCountTemplate : '{n}',
|
|
12
|
+
group: data.hwioCountGroup ?? '',
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Formats an integer with an optional thousands separator into the template. */
|
|
16
|
+
export function hwioFormatCount(n, template = '{n}', group = '') {
|
|
17
|
+
const rounded = Math.round(n);
|
|
18
|
+
const digits = String(Math.abs(rounded));
|
|
19
|
+
const grouped = group ? digits.replace(/\B(?=(\d{3})+(?!\d))/g, group) : digits;
|
|
20
|
+
return template.replace('{n}', `${rounded < 0 ? '-' : ''}${grouped}`);
|
|
21
|
+
}
|
|
22
|
+
/** Ease-out cubic on 0..1: fast start, calm settle (the motion rule: ease-out, nothing bounces). */
|
|
23
|
+
export function hwioEaseOut(progress) {
|
|
24
|
+
const p = Math.min(Math.max(progress, 0), 1);
|
|
25
|
+
return 1 - Math.pow(1 - p, 3);
|
|
26
|
+
}
|
|
27
|
+
/** The number shown at `progress` (0..1) of the count. */
|
|
28
|
+
export function hwioCountAt(spec, progress) {
|
|
29
|
+
return Math.round(spec.from + (spec.to - spec.from) * hwioEaseOut(progress));
|
|
30
|
+
}
|
package/dist/counter.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Count-up
|
|
3
|
-
* without
|
|
2
|
+
* Count-up of figures, once each, when they enter the viewport; the final value is what the server
|
|
3
|
+
* rendered, so without JavaScript or under reduced motion the figure simply shows.
|
|
4
|
+
*
|
|
5
|
+
* - `[data-hwio-count="7000" data-hwio-count-template="{n}+" data-hwio-count-group=" "]`
|
|
6
|
+
* (HWioKeyFigure): each figure counts on its own (800 ms, ease-out) from `data-hwio-count-from`
|
|
7
|
+
* (default 0) and ends on its exact server text (non-breaking spaces included). Below the fold the
|
|
8
|
+
* start value is set while nobody sees it; a figure already in view counts at once.
|
|
9
|
+
* - `[data-hwio-counter data-target="120" data-suffix="+"]` (HWioStatsBand, unchanged): every
|
|
10
|
+
* counter of the page counts together when the first one enters the view.
|
|
4
11
|
*/
|
|
5
12
|
export declare function hwioCounterRuntime(): import("./mount.js").HWioBinder;
|
package/dist/counter.js
CHANGED
|
@@ -1,25 +1,85 @@
|
|
|
1
1
|
import { hwioMount } from './mount.js';
|
|
2
|
+
import { hwioCountAt, hwioFormatCount, hwioParseCount } from './count-core.js';
|
|
3
|
+
/** Count-up duration of a single figure (`[data-hwio-count]`). */
|
|
4
|
+
const COUNT_MS = 800;
|
|
2
5
|
/**
|
|
3
|
-
* Count-up
|
|
4
|
-
* without
|
|
6
|
+
* Count-up of figures, once each, when they enter the viewport; the final value is what the server
|
|
7
|
+
* rendered, so without JavaScript or under reduced motion the figure simply shows.
|
|
8
|
+
*
|
|
9
|
+
* - `[data-hwio-count="7000" data-hwio-count-template="{n}+" data-hwio-count-group=" "]`
|
|
10
|
+
* (HWioKeyFigure): each figure counts on its own (800 ms, ease-out) from `data-hwio-count-from`
|
|
11
|
+
* (default 0) and ends on its exact server text (non-breaking spaces included). Below the fold the
|
|
12
|
+
* start value is set while nobody sees it; a figure already in view counts at once.
|
|
13
|
+
* - `[data-hwio-counter data-target="120" data-suffix="+"]` (HWioStatsBand, unchanged): every
|
|
14
|
+
* counter of the page counts together when the first one enters the view.
|
|
5
15
|
*/
|
|
6
16
|
export function hwioCounterRuntime() {
|
|
7
17
|
let observer = null;
|
|
18
|
+
let countObserver = null;
|
|
8
19
|
let frame = 0;
|
|
20
|
+
const frames = new Set();
|
|
9
21
|
function cleanup() {
|
|
10
22
|
observer?.disconnect();
|
|
11
23
|
observer = null;
|
|
24
|
+
countObserver?.disconnect();
|
|
25
|
+
countObserver = null;
|
|
12
26
|
if (frame)
|
|
13
27
|
cancelAnimationFrame(frame);
|
|
14
28
|
frame = 0;
|
|
29
|
+
frames.forEach((f) => cancelAnimationFrame(f));
|
|
30
|
+
frames.clear();
|
|
15
31
|
}
|
|
16
|
-
|
|
32
|
+
const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
33
|
+
function bindCounts() {
|
|
34
|
+
const els = Array.from(document.querySelectorAll('[data-hwio-count]:not([data-hwio-count-bound])'));
|
|
35
|
+
if (!els.length)
|
|
36
|
+
return;
|
|
37
|
+
els.forEach((el) => (el.dataset.hwioCountBound = 'true'));
|
|
38
|
+
if (reduced() || !('IntersectionObserver' in window))
|
|
39
|
+
return;
|
|
40
|
+
const finals = new WeakMap();
|
|
41
|
+
const run = (el) => {
|
|
42
|
+
const spec = hwioParseCount(el.dataset);
|
|
43
|
+
if (!spec)
|
|
44
|
+
return;
|
|
45
|
+
const start = performance.now();
|
|
46
|
+
const step = (now) => {
|
|
47
|
+
const progress = Math.min((now - start) / COUNT_MS, 1);
|
|
48
|
+
if (progress < 1) {
|
|
49
|
+
el.textContent = hwioFormatCount(hwioCountAt(spec, progress), spec.template, spec.group);
|
|
50
|
+
frames.add(requestAnimationFrame(step));
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
el.textContent = finals.get(el) ?? el.textContent;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
frames.add(requestAnimationFrame(step));
|
|
57
|
+
};
|
|
58
|
+
countObserver ??= new IntersectionObserver((entries) => {
|
|
59
|
+
entries.forEach((entry) => {
|
|
60
|
+
if (!entry.isIntersecting)
|
|
61
|
+
return;
|
|
62
|
+
countObserver?.unobserve(entry.target);
|
|
63
|
+
run(entry.target);
|
|
64
|
+
});
|
|
65
|
+
}, { threshold: 0.4 });
|
|
66
|
+
els.forEach((el) => {
|
|
67
|
+
const spec = hwioParseCount(el.dataset);
|
|
68
|
+
if (!spec)
|
|
69
|
+
return;
|
|
70
|
+
finals.set(el, el.textContent ?? '');
|
|
71
|
+
if (el.getBoundingClientRect().top > window.innerHeight)
|
|
72
|
+
el.textContent = hwioFormatCount(spec.from, spec.template, spec.group);
|
|
73
|
+
countObserver?.observe(el);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function bindCounters() {
|
|
17
77
|
const counters = Array.from(document.querySelectorAll('[data-hwio-counter]:not([data-hwio-counter-bound])'));
|
|
18
78
|
if (!counters.length)
|
|
19
79
|
return;
|
|
20
80
|
counters.forEach((c) => (c.dataset.hwioCounterBound = 'true'));
|
|
21
81
|
const finish = () => counters.forEach((c) => { c.textContent = `${parseInt(c.dataset.target ?? '0', 10)}${c.dataset.suffix ?? ''}`; c.dataset.hwioCounterActive = 'true'; });
|
|
22
|
-
if (
|
|
82
|
+
if (reduced())
|
|
23
83
|
return finish();
|
|
24
84
|
const animate = () => {
|
|
25
85
|
const start = performance.now();
|
|
@@ -48,5 +108,9 @@ export function hwioCounterRuntime() {
|
|
|
48
108
|
}, { threshold: 0.3 });
|
|
49
109
|
observer.observe(counters[0]);
|
|
50
110
|
}
|
|
111
|
+
function bind() {
|
|
112
|
+
bindCounts();
|
|
113
|
+
bindCounters();
|
|
114
|
+
}
|
|
51
115
|
return hwioMount('counter', { bind, cleanup });
|
|
52
116
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropdowns built on `<details data-hwio-dropdown>` (HWioLanguageSwitcher's dropdown variant). A
|
|
3
|
+
* native `<details>` stays open over the page until its own summary is clicked again; this runtime
|
|
4
|
+
* closes it the way a menu button is expected to close (WAI-ARIA disclosure pattern, WCAG 2.1.1 and
|
|
5
|
+
* 1.4.13):
|
|
6
|
+
* - a click or tap outside it;
|
|
7
|
+
* - Escape, with focus back on its summary when focus was inside;
|
|
8
|
+
* - focus leaving it (Tab past the last item);
|
|
9
|
+
* - another dropdown opening, or another runtime announcing a popup (HWioHeader's mega panels and
|
|
10
|
+
* drawer, through `hwio:popupopen`). Opening a dropdown announces it, so an open mega panel closes.
|
|
11
|
+
*/
|
|
12
|
+
export declare function hwioDropdownRuntime(): import("./mount.js").HWioBinder;
|
package/dist/dropdown.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { hwioMount } from './mount.js';
|
|
2
|
+
import { hwioAnnouncePopup, hwioOnPopup } from './popup.js';
|
|
3
|
+
/**
|
|
4
|
+
* Dropdowns built on `<details data-hwio-dropdown>` (HWioLanguageSwitcher's dropdown variant). A
|
|
5
|
+
* native `<details>` stays open over the page until its own summary is clicked again; this runtime
|
|
6
|
+
* closes it the way a menu button is expected to close (WAI-ARIA disclosure pattern, WCAG 2.1.1 and
|
|
7
|
+
* 1.4.13):
|
|
8
|
+
* - a click or tap outside it;
|
|
9
|
+
* - Escape, with focus back on its summary when focus was inside;
|
|
10
|
+
* - focus leaving it (Tab past the last item);
|
|
11
|
+
* - another dropdown opening, or another runtime announcing a popup (HWioHeader's mega panels and
|
|
12
|
+
* drawer, through `hwio:popupopen`). Opening a dropdown announces it, so an open mega panel closes.
|
|
13
|
+
*/
|
|
14
|
+
export function hwioDropdownRuntime() {
|
|
15
|
+
let controller = null;
|
|
16
|
+
function cleanup() {
|
|
17
|
+
controller?.abort();
|
|
18
|
+
controller = null;
|
|
19
|
+
}
|
|
20
|
+
function bind() {
|
|
21
|
+
cleanup();
|
|
22
|
+
const dropdowns = Array.from(document.querySelectorAll('details[data-hwio-dropdown]'));
|
|
23
|
+
if (!dropdowns.length)
|
|
24
|
+
return;
|
|
25
|
+
controller = new AbortController();
|
|
26
|
+
const { signal } = controller;
|
|
27
|
+
const close = (dropdown, restoreFocus = false) => {
|
|
28
|
+
if (!dropdown.open)
|
|
29
|
+
return;
|
|
30
|
+
const hadFocus = dropdown.contains(document.activeElement);
|
|
31
|
+
dropdown.open = false;
|
|
32
|
+
if (restoreFocus && hadFocus)
|
|
33
|
+
dropdown.querySelector('summary')?.focus();
|
|
34
|
+
};
|
|
35
|
+
document.addEventListener('click', (event) => {
|
|
36
|
+
const target = event.target;
|
|
37
|
+
dropdowns.forEach((d) => {
|
|
38
|
+
if (!target || !d.contains(target))
|
|
39
|
+
close(d);
|
|
40
|
+
});
|
|
41
|
+
}, { signal });
|
|
42
|
+
document.addEventListener('keydown', (event) => {
|
|
43
|
+
if (event.key === 'Escape')
|
|
44
|
+
dropdowns.forEach((d) => close(d, true));
|
|
45
|
+
}, { signal });
|
|
46
|
+
dropdowns.forEach((dropdown) => {
|
|
47
|
+
dropdown.addEventListener('focusout', (event) => {
|
|
48
|
+
const next = event.relatedTarget;
|
|
49
|
+
if (next && !dropdown.contains(next))
|
|
50
|
+
close(dropdown);
|
|
51
|
+
}, { signal });
|
|
52
|
+
dropdown.addEventListener('toggle', () => {
|
|
53
|
+
if (!dropdown.open)
|
|
54
|
+
return;
|
|
55
|
+
dropdowns.forEach((other) => {
|
|
56
|
+
if (other !== dropdown)
|
|
57
|
+
close(other);
|
|
58
|
+
});
|
|
59
|
+
hwioAnnouncePopup(dropdown);
|
|
60
|
+
}, { signal });
|
|
61
|
+
});
|
|
62
|
+
hwioOnPopup((owner) => {
|
|
63
|
+
dropdowns.forEach((d) => {
|
|
64
|
+
if (d !== owner && !d.contains(owner))
|
|
65
|
+
close(d);
|
|
66
|
+
});
|
|
67
|
+
}, signal);
|
|
68
|
+
}
|
|
69
|
+
return hwioMount('dropdown', { bind, cleanup });
|
|
70
|
+
}
|
package/dist/iso.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isometric scenes drawn with the Brand Guidelines' kit (the artwork library's `make isometric`;
|
|
3
|
+
* HWioIsoScene inlines one): when a `[data-hwio-iso]` scene enters the viewport it builds itself
|
|
4
|
+
* once. The kit's class names are the contract:
|
|
5
|
+
* - `.iso-object` groups fade in and settle 4 px, in drawing order (300 ms, 90 ms apart);
|
|
6
|
+
* - `g.iso-radio` links fade in after the objects;
|
|
7
|
+
* - each `g.iso-link` draws its `.iso-trace` path from pad to pad (700 ms, ease-out; the kit gives
|
|
8
|
+
* every trace `pathLength="1"`), and its `.iso-pad` ends appear as the trace leaves and arrives
|
|
9
|
+
* (`data-end="end"` marks the far pad).
|
|
10
|
+
* The runtime only sets transition delays (CSSOM, allowed by a strict style-src) and two classes:
|
|
11
|
+
* `hwio-iso-anim` hides the parts, `hwio-iso-play` plays them. Without this runtime, under
|
|
12
|
+
* prefers-reduced-motion or without IntersectionObserver the scene shows complete at once.
|
|
13
|
+
* Transforms, opacity and stroke-dashoffset only, so the layout never moves.
|
|
14
|
+
*/
|
|
15
|
+
export declare function hwioIsoRuntime(): import("./mount.js").HWioBinder;
|
package/dist/iso.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { hwioMount } from './mount.js';
|
|
2
|
+
/** Timing of a scene build (ms): objects settle one after another, then the traces draw. */
|
|
3
|
+
const STEP = 90;
|
|
4
|
+
const SETTLE = 300;
|
|
5
|
+
const DRAW = 700;
|
|
6
|
+
const LINK_GAP = 250;
|
|
7
|
+
/**
|
|
8
|
+
* Isometric scenes drawn with the Brand Guidelines' kit (the artwork library's `make isometric`;
|
|
9
|
+
* HWioIsoScene inlines one): when a `[data-hwio-iso]` scene enters the viewport it builds itself
|
|
10
|
+
* once. The kit's class names are the contract:
|
|
11
|
+
* - `.iso-object` groups fade in and settle 4 px, in drawing order (300 ms, 90 ms apart);
|
|
12
|
+
* - `g.iso-radio` links fade in after the objects;
|
|
13
|
+
* - each `g.iso-link` draws its `.iso-trace` path from pad to pad (700 ms, ease-out; the kit gives
|
|
14
|
+
* every trace `pathLength="1"`), and its `.iso-pad` ends appear as the trace leaves and arrives
|
|
15
|
+
* (`data-end="end"` marks the far pad).
|
|
16
|
+
* The runtime only sets transition delays (CSSOM, allowed by a strict style-src) and two classes:
|
|
17
|
+
* `hwio-iso-anim` hides the parts, `hwio-iso-play` plays them. Without this runtime, under
|
|
18
|
+
* prefers-reduced-motion or without IntersectionObserver the scene shows complete at once.
|
|
19
|
+
* Transforms, opacity and stroke-dashoffset only, so the layout never moves.
|
|
20
|
+
*/
|
|
21
|
+
export function hwioIsoRuntime() {
|
|
22
|
+
let observer = null;
|
|
23
|
+
function bind() {
|
|
24
|
+
const scenes = Array.from(document.querySelectorAll('[data-hwio-iso]:not([data-hwio-iso-bound])'));
|
|
25
|
+
if (!scenes.length)
|
|
26
|
+
return;
|
|
27
|
+
scenes.forEach((scene) => (scene.dataset.hwioIsoBound = 'true'));
|
|
28
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || !('IntersectionObserver' in window))
|
|
29
|
+
return;
|
|
30
|
+
scenes.forEach((scene) => {
|
|
31
|
+
const objects = Array.from(scene.querySelectorAll('.iso-object'));
|
|
32
|
+
objects.forEach((g, i) => (g.style.transitionDelay = `${i * STEP}ms`));
|
|
33
|
+
const objectsDone = Math.max(0, objects.length - 1) * STEP + SETTLE;
|
|
34
|
+
scene.querySelectorAll('g.iso-radio').forEach((g) => (g.style.transitionDelay = `${objectsDone}ms`));
|
|
35
|
+
const links = Array.from(scene.querySelectorAll('g.iso-link:not(.iso-radio)'));
|
|
36
|
+
links.forEach((link, k) => {
|
|
37
|
+
const start = objectsDone + 100 + k * LINK_GAP;
|
|
38
|
+
const trace = link.querySelector('.iso-trace');
|
|
39
|
+
if (trace)
|
|
40
|
+
trace.style.transitionDelay = `${start}ms`;
|
|
41
|
+
link.querySelectorAll('.iso-pad').forEach((pad) => {
|
|
42
|
+
pad.style.transitionDelay = `${pad.dataset.end === 'end' ? start + DRAW - 100 : start}ms`;
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
scene.classList.add('hwio-iso-anim');
|
|
46
|
+
});
|
|
47
|
+
observer ??= new IntersectionObserver((entries) => {
|
|
48
|
+
entries.forEach((entry) => {
|
|
49
|
+
if (!entry.isIntersecting)
|
|
50
|
+
return;
|
|
51
|
+
observer?.unobserve(entry.target);
|
|
52
|
+
entry.target.classList.add('hwio-iso-play');
|
|
53
|
+
});
|
|
54
|
+
}, { threshold: 0.3 });
|
|
55
|
+
scenes.forEach((scene) => observer.observe(scene));
|
|
56
|
+
}
|
|
57
|
+
return hwioMount('iso', { bind, cleanup: () => { observer?.disconnect(); observer = null; } });
|
|
58
|
+
}
|
package/dist/nav.d.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* opens HWioHeader's mega panels
|
|
6
6
|
* ([data-hwio-mega-trigger] buttons and their [data-hwio-mega-panel]; hover with intent on pointer
|
|
7
7
|
* devices, click, ArrowDown into the panel, Escape / outside click / focus leaving close; one at a
|
|
8
|
-
* time), and marks the nav link whose section is in view ([data-hwio-scrollspy] nav with same-page
|
|
9
|
-
* anchors) with aria-current="location".
|
|
8
|
+
* time; the click that follows a hover-open keeps the panel open, the next one closes it), and marks the nav link whose section is in view ([data-hwio-scrollspy] nav with same-page
|
|
9
|
+
* anchors) with aria-current="location". Opening a mega panel or the drawer announces it
|
|
10
|
+
* (`hwio:popupopen`, popup.ts), so an open dropdown (HWioLanguageSwitcher) closes; a dropdown that
|
|
11
|
+
* opens closes the mega panels the same way.
|
|
10
12
|
*/
|
|
11
13
|
export declare function hwioNavRuntime(): import("./mount.js").HWioBinder;
|
package/dist/nav.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hwioMount } from './mount.js';
|
|
2
|
+
import { hwioAnnouncePopup, hwioOnPopup } from './popup.js';
|
|
2
3
|
/**
|
|
3
4
|
* Header behaviours: drives the DaisyUI drawer from its [data-hwio-drawer-toggle] buttons (Enter and
|
|
4
5
|
* Space work because they are buttons; aria-expanded follows the checkbox, focus moves to the panel's
|
|
@@ -6,8 +7,10 @@ import { hwioMount } from './mount.js';
|
|
|
6
7
|
* opens HWioHeader's mega panels
|
|
7
8
|
* ([data-hwio-mega-trigger] buttons and their [data-hwio-mega-panel]; hover with intent on pointer
|
|
8
9
|
* devices, click, ArrowDown into the panel, Escape / outside click / focus leaving close; one at a
|
|
9
|
-
* time), and marks the nav link whose section is in view ([data-hwio-scrollspy] nav with same-page
|
|
10
|
-
* anchors) with aria-current="location".
|
|
10
|
+
* time; the click that follows a hover-open keeps the panel open, the next one closes it), and marks the nav link whose section is in view ([data-hwio-scrollspy] nav with same-page
|
|
11
|
+
* anchors) with aria-current="location". Opening a mega panel or the drawer announces it
|
|
12
|
+
* (`hwio:popupopen`, popup.ts), so an open dropdown (HWioLanguageSwitcher) closes; a dropdown that
|
|
13
|
+
* opens closes the mega panels the same way.
|
|
11
14
|
*/
|
|
12
15
|
export function hwioNavRuntime() {
|
|
13
16
|
let controller = null;
|
|
@@ -38,6 +41,8 @@ export function hwioNavRuntime() {
|
|
|
38
41
|
const apply = (restoreFocus) => {
|
|
39
42
|
sync();
|
|
40
43
|
if (input.checked) {
|
|
44
|
+
if (side)
|
|
45
|
+
hwioAnnouncePopup(side);
|
|
41
46
|
const first = side?.querySelector('button, a[href], input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])');
|
|
42
47
|
if (first)
|
|
43
48
|
focusWhenVisible(first);
|
|
@@ -72,13 +77,31 @@ export function hwioNavRuntime() {
|
|
|
72
77
|
closeTimer = null;
|
|
73
78
|
} };
|
|
74
79
|
const set = (pair, open) => { pair.trigger.setAttribute('aria-expanded', String(open)); pair.panel.hidden = !open; };
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
// A panel that hover has just opened stays open on the click that follows (pointer users click the
|
|
81
|
+
// item they point at); the next click closes it. Without this, hover opened it and the click shut it.
|
|
82
|
+
let hoverOpened = null;
|
|
83
|
+
const closeAll = () => { cancelClose(); hoverOpened = null; pairs.forEach((p) => set(p, false)); };
|
|
78
84
|
const isOpen = (pair) => pair.trigger.getAttribute('aria-expanded') === 'true';
|
|
85
|
+
const openOne = (pair) => {
|
|
86
|
+
cancelClose();
|
|
87
|
+
const wasOpen = isOpen(pair);
|
|
88
|
+
pairs.forEach((p) => set(p, p === pair));
|
|
89
|
+
if (!wasOpen)
|
|
90
|
+
hwioAnnouncePopup(pair.panel);
|
|
91
|
+
};
|
|
92
|
+
const scheduleClose = () => { cancelClose(); closeTimer = setTimeout(closeAll, 180); };
|
|
79
93
|
pairs.forEach((pair) => {
|
|
80
94
|
const { trigger, panel } = pair;
|
|
81
|
-
trigger.addEventListener('click', () =>
|
|
95
|
+
trigger.addEventListener('click', () => {
|
|
96
|
+
if (isOpen(pair) && hoverOpened === trigger) {
|
|
97
|
+
hoverOpened = null;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (isOpen(pair))
|
|
101
|
+
closeAll();
|
|
102
|
+
else
|
|
103
|
+
openOne(pair);
|
|
104
|
+
}, { signal });
|
|
82
105
|
trigger.addEventListener('keydown', (e) => {
|
|
83
106
|
if (e.key !== 'ArrowDown')
|
|
84
107
|
return;
|
|
@@ -87,7 +110,11 @@ export function hwioNavRuntime() {
|
|
|
87
110
|
panel.querySelector('a[href], button')?.focus();
|
|
88
111
|
}, { signal });
|
|
89
112
|
if (hoverable) {
|
|
90
|
-
trigger.addEventListener('mouseenter', () =>
|
|
113
|
+
trigger.addEventListener('mouseenter', () => {
|
|
114
|
+
const wasOpen = isOpen(pair);
|
|
115
|
+
openOne(pair);
|
|
116
|
+
hoverOpened = wasOpen ? null : trigger;
|
|
117
|
+
}, { signal });
|
|
91
118
|
trigger.addEventListener('mouseleave', scheduleClose, { signal });
|
|
92
119
|
panel.addEventListener('mouseenter', cancelClose, { signal });
|
|
93
120
|
panel.addEventListener('mouseleave', scheduleClose, { signal });
|
|
@@ -108,6 +135,11 @@ export function hwioNavRuntime() {
|
|
|
108
135
|
if (!pairs.some((p) => p.trigger.contains(node) || p.panel.contains(node)))
|
|
109
136
|
closeAll();
|
|
110
137
|
}, { signal });
|
|
138
|
+
// Another popup (a language dropdown, the drawer) opened: close the panels unless it sits in one.
|
|
139
|
+
hwioOnPopup((owner) => {
|
|
140
|
+
if (!pairs.some((p) => p.panel === owner || p.panel.contains(owner)))
|
|
141
|
+
closeAll();
|
|
142
|
+
}, signal);
|
|
111
143
|
}
|
|
112
144
|
const nav = document.querySelector('[data-hwio-scrollspy]');
|
|
113
145
|
if (!nav || !('IntersectionObserver' in window))
|
package/dist/popup.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One popup at a time across runtimes. A runtime that opens something which floats over the page
|
|
3
|
+
* (HWioHeader's mega panel or drawer, a `details[data-hwio-dropdown]`) announces the element it
|
|
4
|
+
* opened with `hwio:popupopen`; every other runtime closes what it has open unless that element is
|
|
5
|
+
* the announced one or contains it. The runtimes stay independent: neither imports the other.
|
|
6
|
+
*/
|
|
7
|
+
export declare const HWIO_POPUP_EVENT = "hwio:popupopen";
|
|
8
|
+
export interface HWioPopupDetail {
|
|
9
|
+
owner: Element;
|
|
10
|
+
}
|
|
11
|
+
/** Tell the other runtimes that `owner` has just opened. */
|
|
12
|
+
export declare function hwioAnnouncePopup(owner: Element): void;
|
|
13
|
+
/** Run `handler` whenever another runtime opens a popup; detaches with `signal`. */
|
|
14
|
+
export declare function hwioOnPopup(handler: (owner: Element) => void, signal: AbortSignal): void;
|
package/dist/popup.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One popup at a time across runtimes. A runtime that opens something which floats over the page
|
|
3
|
+
* (HWioHeader's mega panel or drawer, a `details[data-hwio-dropdown]`) announces the element it
|
|
4
|
+
* opened with `hwio:popupopen`; every other runtime closes what it has open unless that element is
|
|
5
|
+
* the announced one or contains it. The runtimes stay independent: neither imports the other.
|
|
6
|
+
*/
|
|
7
|
+
export const HWIO_POPUP_EVENT = 'hwio:popupopen';
|
|
8
|
+
/** Tell the other runtimes that `owner` has just opened. */
|
|
9
|
+
export function hwioAnnouncePopup(owner) {
|
|
10
|
+
document.dispatchEvent(new CustomEvent(HWIO_POPUP_EVENT, { detail: { owner } }));
|
|
11
|
+
}
|
|
12
|
+
/** Run `handler` whenever another runtime opens a popup; detaches with `signal`. */
|
|
13
|
+
export function hwioOnPopup(handler, signal) {
|
|
14
|
+
document.addEventListener(HWIO_POPUP_EVENT, (event) => {
|
|
15
|
+
const owner = event.detail?.owner;
|
|
16
|
+
if (owner)
|
|
17
|
+
handler(owner);
|
|
18
|
+
}, { signal });
|
|
19
|
+
}
|
package/dist/reveal.d.ts
CHANGED
|
@@ -1,2 +1,19 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Class on <html> while the reveal runtime runs; the stylesheet hides waiting content only under it. */
|
|
2
|
+
export declare const HWIO_REVEAL_CLASS = "hwio-reveal-on";
|
|
3
|
+
declare global {
|
|
4
|
+
interface Window {
|
|
5
|
+
__hwioRevealSwap?: boolean;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Scroll reveal for [data-hwio-reveal]: each element fades in and settles once, the first time it
|
|
10
|
+
* enters the viewport (data-hwio-revealed). `data-hwio-reveal-delay="<ms>"` staggers siblings; it is
|
|
11
|
+
* applied through the CSSOM, which a strict style-src allows.
|
|
12
|
+
*
|
|
13
|
+
* Fail-safe (owner, 2026-10-30): content waiting for its reveal is hidden only once this runtime has
|
|
14
|
+
* started, through the class `hwio-reveal-on` it sets on <html>. If the module never runs (a script
|
|
15
|
+
* error, a blocked or failed import), nothing is hidden and every section shows. Under
|
|
16
|
+
* prefers-reduced-motion, or without IntersectionObserver, everything is revealed at once and the
|
|
17
|
+
* class is never set.
|
|
18
|
+
*/
|
|
2
19
|
export declare function hwioRevealRuntime(): import("./mount.js").HWioBinder;
|
package/dist/reveal.js
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
import { hwioMount } from './mount.js';
|
|
2
|
-
/**
|
|
2
|
+
/** Class on <html> while the reveal runtime runs; the stylesheet hides waiting content only under it. */
|
|
3
|
+
export const HWIO_REVEAL_CLASS = 'hwio-reveal-on';
|
|
4
|
+
/**
|
|
5
|
+
* Scroll reveal for [data-hwio-reveal]: each element fades in and settles once, the first time it
|
|
6
|
+
* enters the viewport (data-hwio-revealed). `data-hwio-reveal-delay="<ms>"` staggers siblings; it is
|
|
7
|
+
* applied through the CSSOM, which a strict style-src allows.
|
|
8
|
+
*
|
|
9
|
+
* Fail-safe (owner, 2026-10-30): content waiting for its reveal is hidden only once this runtime has
|
|
10
|
+
* started, through the class `hwio-reveal-on` it sets on <html>. If the module never runs (a script
|
|
11
|
+
* error, a blocked or failed import), nothing is hidden and every section shows. Under
|
|
12
|
+
* prefers-reduced-motion, or without IntersectionObserver, everything is revealed at once and the
|
|
13
|
+
* class is never set.
|
|
14
|
+
*/
|
|
3
15
|
export function hwioRevealRuntime() {
|
|
4
16
|
let observer = null;
|
|
17
|
+
// With Astro's ClientRouter the next document's <html> replaces this one's attributes; carry the
|
|
18
|
+
// class across the swap so revealed-on-arrival content of the next page never flashes.
|
|
19
|
+
if (!window.__hwioRevealSwap) {
|
|
20
|
+
window.__hwioRevealSwap = true;
|
|
21
|
+
document.addEventListener('astro:before-swap', (event) => {
|
|
22
|
+
const next = event.newDocument;
|
|
23
|
+
if (next && document.documentElement.classList.contains(HWIO_REVEAL_CLASS))
|
|
24
|
+
next.documentElement.classList.add(HWIO_REVEAL_CLASS);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
5
27
|
function bind() {
|
|
6
28
|
const targets = Array.from(document.querySelectorAll('[data-hwio-reveal]:not([data-hwio-revealed])'));
|
|
7
29
|
if (!targets.length)
|
|
@@ -10,6 +32,14 @@ export function hwioRevealRuntime() {
|
|
|
10
32
|
targets.forEach((el) => (el.dataset.hwioRevealed = 'true'));
|
|
11
33
|
return;
|
|
12
34
|
}
|
|
35
|
+
targets.forEach((el) => {
|
|
36
|
+
const delay = Number(el.dataset.hwioRevealDelay);
|
|
37
|
+
if (Number.isFinite(delay) && delay > 0)
|
|
38
|
+
el.style.transitionDelay = `${Math.min(delay, 1000)}ms`;
|
|
39
|
+
});
|
|
40
|
+
document.documentElement.classList.add(HWIO_REVEAL_CLASS);
|
|
41
|
+
// Threshold 0: an element taller than the viewport still reveals (a 10 % threshold never fires
|
|
42
|
+
// for a section ten screens tall, which would leave it hidden).
|
|
13
43
|
observer ??= new IntersectionObserver((entries) => {
|
|
14
44
|
entries.forEach((entry) => {
|
|
15
45
|
if (!entry.isIntersecting)
|
|
@@ -17,7 +47,7 @@ export function hwioRevealRuntime() {
|
|
|
17
47
|
entry.target.dataset.hwioRevealed = 'true';
|
|
18
48
|
observer?.unobserve(entry.target);
|
|
19
49
|
});
|
|
20
|
-
}, { threshold: 0
|
|
50
|
+
}, { threshold: 0, rootMargin: '0px 0px -50px 0px' });
|
|
21
51
|
targets.forEach((el) => observer.observe(el));
|
|
22
52
|
}
|
|
23
53
|
return hwioMount('reveal', { bind, cleanup: () => { observer?.disconnect(); observer = null; } });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hubpav/hwio-web-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Framework-free browser runtime for the HARDWARIO websites: consent (Consent Mode v2 + GTM), HubSpot forms with Turnstile, attribution, theme, CTA tracking and small behaviours. View-transition safe.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,7 +25,11 @@
|
|
|
25
25
|
"./track": "./dist/track.js",
|
|
26
26
|
"./reveal": "./dist/reveal.js",
|
|
27
27
|
"./counter": "./dist/counter.js",
|
|
28
|
+
"./count-core": "./dist/count-core.js",
|
|
28
29
|
"./nav": "./dist/nav.js",
|
|
30
|
+
"./dropdown": "./dist/dropdown.js",
|
|
31
|
+
"./popup": "./dist/popup.js",
|
|
32
|
+
"./iso": "./dist/iso.js",
|
|
29
33
|
"./accordion": "./dist/accordion.js",
|
|
30
34
|
"./inline": "./dist/inline.js",
|
|
31
35
|
"./package.json": "./package.json"
|