@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 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 for [data-hwio-counter data-target="120" data-suffix="+"]. Renders the final value
3
- * without animation under reduced motion; otherwise eases from 0 when the first counter enters view.
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 for [data-hwio-counter data-target="120" data-suffix="+"]. Renders the final value
4
- * without animation under reduced motion; otherwise eases from 0 when the first counter enters view.
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
- function bind() {
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 (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
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;
@@ -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
- const closeAll = () => { cancelClose(); pairs.forEach((p) => set(p, false)); };
76
- const openOne = (pair) => { cancelClose(); pairs.forEach((p) => set(p, p === pair)); };
77
- const scheduleClose = () => { cancelClose(); closeTimer = setTimeout(closeAll, 180); };
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', () => (isOpen(pair) ? closeAll() : openOne(pair)), { signal });
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', () => openOne(pair), { signal });
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))
@@ -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
- /** Scroll reveal for [data-hwio-reveal]: sets data-hwio-revealed once in view; reduced motion reveals immediately. */
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
- /** Scroll reveal for [data-hwio-reveal]: sets data-hwio-revealed once in view; reduced motion reveals immediately. */
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.1, rootMargin: '0px 0px -50px 0px' });
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.8.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"