@hubpav/hwio-web-runtime 0.9.0 → 0.10.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.
@@ -7,6 +7,6 @@
7
7
  * - Escape, with focus back on its summary when focus was inside;
8
8
  * - focus leaving it (Tab past the last item);
9
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.
10
+ * small-screen menu, through `hwio:popupopen`). Opening a dropdown announces it, so an open mega panel closes.
11
11
  */
12
12
  export declare function hwioDropdownRuntime(): import("./mount.js").HWioBinder;
package/dist/dropdown.js CHANGED
@@ -9,7 +9,7 @@ import { hwioAnnouncePopup, hwioOnPopup } from './popup.js';
9
9
  * - Escape, with focus back on its summary when focus was inside;
10
10
  * - focus leaving it (Tab past the last item);
11
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.
12
+ * small-screen menu, through `hwio:popupopen`). Opening a dropdown announces it, so an open mega panel closes.
13
13
  */
14
14
  export function hwioDropdownRuntime() {
15
15
  let controller = null;
package/dist/nav.d.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Header behaviours: drives the DaisyUI drawer from its [data-hwio-drawer-toggle] buttons (Enter and
3
- * Space work because they are buttons; aria-expanded follows the checkbox, focus moves to the panel's
4
- * first control on open and back to the trigger on close, Escape closes, a followed link closes),
5
- * opens HWioHeader's mega panels
2
+ * Header behaviours: opens HWioHeader's small-screen menu from its [data-hwio-menu-toggle] button into its
3
+ * [data-hwio-menu-panel] sheet (aria-expanded and the button's label follow; the page is locked without
4
+ * moving, its scrollbar's width kept as padding, and made inert behind the sheet; focus moves to the first
5
+ * item without scrolling and back to the toggle on close; the toggle, Escape, a followed link, another
6
+ * popup and a resize to the desktop layout close it), opens HWioHeader's mega panels
6
7
  * ([data-hwio-mega-trigger] buttons and their [data-hwio-mega-panel]; hover with intent on pointer
7
8
  * devices, click, ArrowDown into the panel, Escape / outside click / focus leaving close; one at a
8
9
  * 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
+ * anchors) with aria-current="location". Opening a mega panel or the small-screen menu announces it
10
11
  * (`hwio:popupopen`, popup.ts), so an open dropdown (HWioLanguageSwitcher) closes; a dropdown that
11
12
  * opens closes the mega panels the same way.
12
13
  */
package/dist/nav.js CHANGED
@@ -1,21 +1,26 @@
1
1
  import { hwioMount } from './mount.js';
2
2
  import { hwioAnnouncePopup, hwioOnPopup } from './popup.js';
3
3
  /**
4
- * Header behaviours: drives the DaisyUI drawer from its [data-hwio-drawer-toggle] buttons (Enter and
5
- * Space work because they are buttons; aria-expanded follows the checkbox, focus moves to the panel's
6
- * first control on open and back to the trigger on close, Escape closes, a followed link closes),
7
- * opens HWioHeader's mega panels
4
+ * Header behaviours: opens HWioHeader's small-screen menu from its [data-hwio-menu-toggle] button into its
5
+ * [data-hwio-menu-panel] sheet (aria-expanded and the button's label follow; the page is locked without
6
+ * moving, its scrollbar's width kept as padding, and made inert behind the sheet; focus moves to the first
7
+ * item without scrolling and back to the toggle on close; the toggle, Escape, a followed link, another
8
+ * popup and a resize to the desktop layout close it), opens HWioHeader's mega panels
8
9
  * ([data-hwio-mega-trigger] buttons and their [data-hwio-mega-panel]; hover with intent on pointer
9
10
  * devices, click, ArrowDown into the panel, Escape / outside click / focus leaving close; one at a
10
11
  * 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
+ * anchors) with aria-current="location". Opening a mega panel or the small-screen menu announces it
12
13
  * (`hwio:popupopen`, popup.ts), so an open dropdown (HWioLanguageSwitcher) closes; a dropdown that
13
14
  * opens closes the mega panels the same way.
14
15
  */
15
16
  export function hwioNavRuntime() {
16
17
  let controller = null;
17
18
  let observer = null;
19
+ let closeMenus = [];
18
20
  function cleanup() {
21
+ // A page swap with the menu open must not leave the next page locked or inert.
22
+ closeMenus.forEach((close) => close());
23
+ closeMenus = [];
19
24
  controller?.abort();
20
25
  controller = null;
21
26
  observer?.disconnect();
@@ -25,46 +30,89 @@ export function hwioNavRuntime() {
25
30
  cleanup();
26
31
  controller = new AbortController();
27
32
  const { signal } = controller;
28
- document.querySelectorAll('input.drawer-toggle').forEach((input) => {
29
- const side = input.parentElement?.querySelector('.drawer-side') ?? null;
30
- const toggles = Array.from(document.querySelectorAll(`[data-hwio-drawer-toggle="${input.id}"]`));
31
- const trigger = toggles.find((t) => !side?.contains(t)) ?? null;
32
- const sync = () => toggles.forEach((t) => t.setAttribute('aria-expanded', String(input.checked)));
33
- // DaisyUI reveals .drawer-side after a 100 ms visibility delay; retry per frame until the control takes focus.
34
- const focusWhenVisible = (el, frames = 40) => {
35
- if (!input.checked)
36
- return;
37
- el.focus();
38
- if (document.activeElement !== el && frames > 0)
39
- requestAnimationFrame(() => focusWhenVisible(el, frames - 1));
33
+ document.querySelectorAll('[data-hwio-menu-toggle]').forEach((toggle) => {
34
+ const panel = document.querySelector(`[data-hwio-menu-panel="${toggle.dataset.hwioMenuToggle}"]`);
35
+ if (!panel)
36
+ return;
37
+ const header = toggle.closest('header');
38
+ const root = document.documentElement;
39
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
40
+ const desktop = toggle.dataset.hwioMenuUntil ? window.matchMedia(`(min-width: ${toggle.dataset.hwioMenuUntil})`) : null;
41
+ let inerted = [];
42
+ let hideTimer = null;
43
+ const isOpen = () => toggle.getAttribute('aria-expanded') === 'true';
44
+ const label = (open) => {
45
+ const text = open ? toggle.dataset.hwioLabelClose : toggle.dataset.hwioLabelOpen;
46
+ if (text)
47
+ toggle.setAttribute('aria-label', text);
40
48
  };
41
- const apply = (restoreFocus) => {
42
- sync();
43
- if (input.checked) {
44
- if (side)
45
- hwioAnnouncePopup(side);
46
- const first = side?.querySelector('button, a[href], input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])');
47
- if (first)
48
- focusWhenVisible(first);
49
+ // Lock the page where it is: overflow hidden on the root, its scrollbar's width kept as padding (so
50
+ // the layout keeps its width), and everything outside the header inert, walking up from the header.
51
+ const lock = (on) => {
52
+ if (on) {
53
+ root.style.setProperty('--hwio-scrollbar', `${Math.max(0, window.innerWidth - root.clientWidth)}px`);
54
+ root.classList.add('hwio-scroll-lock');
55
+ for (let node = header; node && node.parentElement && node !== document.body; node = node.parentElement) {
56
+ for (const sibling of Array.from(node.parentElement.children)) {
57
+ if (sibling !== node && sibling instanceof HTMLElement && !sibling.inert && sibling.tagName !== 'SCRIPT') {
58
+ sibling.inert = true;
59
+ inerted.push(sibling);
60
+ }
61
+ }
62
+ }
49
63
  }
50
- else if (restoreFocus) {
51
- trigger?.focus();
64
+ else {
65
+ root.classList.remove('hwio-scroll-lock');
66
+ root.style.removeProperty('--hwio-scrollbar');
67
+ inerted.forEach((el) => { el.inert = false; });
68
+ inerted = [];
52
69
  }
53
70
  };
54
71
  const setOpen = (open, restoreFocus = true) => {
55
- if (input.checked === open)
72
+ if (isOpen() === open)
56
73
  return;
57
- input.checked = open;
58
- apply(restoreFocus);
74
+ toggle.setAttribute('aria-expanded', String(open));
75
+ label(open);
76
+ if (hideTimer) {
77
+ clearTimeout(hideTimer);
78
+ hideTimer = null;
79
+ }
80
+ if (open) {
81
+ panel.hidden = false;
82
+ lock(true);
83
+ // One frame with the closed state rendered, so the sheet fades in instead of appearing.
84
+ requestAnimationFrame(() => { if (isOpen())
85
+ panel.dataset.state = 'open'; });
86
+ hwioAnnouncePopup(panel);
87
+ panel.querySelector('a[href], button, summary, input:not([type="hidden"])')?.focus({ preventScroll: true });
88
+ }
89
+ else {
90
+ panel.dataset.state = 'closed';
91
+ lock(false);
92
+ const hide = () => { if (!isOpen())
93
+ panel.hidden = true; };
94
+ if (reduced.matches)
95
+ hide();
96
+ else
97
+ hideTimer = setTimeout(hide, 200);
98
+ if (restoreFocus)
99
+ toggle.focus({ preventScroll: true });
100
+ }
59
101
  };
60
- sync();
61
- toggles.forEach((t) => t.addEventListener('click', () => setOpen(!input.checked), { signal }));
62
- // Labels (the overlay) still flip the checkbox natively; follow them.
63
- input.addEventListener('change', () => apply(true), { signal });
64
- // A followed link closes the drawer without stealing focus from the navigation target (same-page anchors).
65
- side?.querySelectorAll('a[href]').forEach((a) => a.addEventListener('click', () => setOpen(false, false), { signal }));
66
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && input.checked)
102
+ closeMenus.push(() => { if (isOpen()) {
103
+ setOpen(false, false);
104
+ panel.hidden = true;
105
+ } });
106
+ toggle.addEventListener('click', () => setOpen(!isOpen()), { signal });
107
+ // A followed link closes the menu without stealing focus from the navigation target (same-page anchors).
108
+ panel.querySelectorAll('a[href]').forEach((a) => a.addEventListener('click', () => setOpen(false, false), { signal }));
109
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen())
67
110
  setOpen(false); }, { signal });
111
+ desktop?.addEventListener('change', (e) => { if (e.matches)
112
+ setOpen(false, false); }, { signal });
113
+ // Another popup (the language dropdown in the bar) opened: close the menu unless it sits in the sheet.
114
+ hwioOnPopup((owner) => { if (owner !== panel && !panel.contains(owner))
115
+ setOpen(false, false); }, signal);
68
116
  });
69
117
  const triggers = Array.from(document.querySelectorAll('[data-hwio-mega-trigger]'));
70
118
  if (triggers.length) {
@@ -135,7 +183,7 @@ export function hwioNavRuntime() {
135
183
  if (!pairs.some((p) => p.trigger.contains(node) || p.panel.contains(node)))
136
184
  closeAll();
137
185
  }, { signal });
138
- // Another popup (a language dropdown, the drawer) opened: close the panels unless it sits in one.
186
+ // Another popup (a language dropdown, the small-screen menu) opened: close the panels unless it sits in one.
139
187
  hwioOnPopup((owner) => {
140
188
  if (!pairs.some((p) => p.panel === owner || p.panel.contains(owner)))
141
189
  closeAll();
package/dist/popup.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * (HWioHeader's mega panel or small-screen menu, a `details[data-hwio-dropdown]`) announces the element it
4
4
  * opened with `hwio:popupopen`; every other runtime closes what it has open unless that element is
5
5
  * the announced one or contains it. The runtimes stay independent: neither imports the other.
6
6
  */
package/dist/popup.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
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
3
+ * (HWioHeader's mega panel or small-screen menu, a `details[data-hwio-dropdown]`) announces the element it
4
4
  * opened with `hwio:popupopen`; every other runtime closes what it has open unless that element is
5
5
  * the announced one or contains it. The runtimes stay independent: neither imports the other.
6
6
  */
@@ -24,6 +24,10 @@ declare global {
24
24
  * Lazy explicit Turnstile: the third-party script loads on first form interaction (focus or
25
25
  * pointer-enter) and the widget id is stored on its form so the submit handler resets exactly
26
26
  * the token it consumed. Markup: <div class="cf-turnstile" data-sitekey="..."> inside a <form>.
27
+ *
28
+ * The widget takes the page's scheme when it renders, and Turnstile cannot change the theme of a
29
+ * rendered widget, so a switch of the site theme (hwio:themechange) redraws every widget in the
30
+ * new scheme; the redrawn widget issues a fresh token.
27
31
  */
28
32
  export declare function hwioTurnstileRuntime(): import("./mount.js").HWioBinder;
29
33
  /** Waits (bounded) for the lazily rendered widget to produce a token before the form is snapshotted. */
package/dist/turnstile.js CHANGED
@@ -35,22 +35,51 @@ function loadApi() {
35
35
  });
36
36
  return window.__hwioTurnstileLoader;
37
37
  }
38
+ function draw(api, container) {
39
+ return api.render(container, { sitekey: container.dataset.sitekey ?? '', theme: hwioResolvedScheme(), size: widgetSize(container) });
40
+ }
41
+ function storeId(form, id) {
42
+ form.hwioTurnstileWidgetId = id;
43
+ form.hwTurnstileWidgetId = id; // legacy-ok
44
+ }
38
45
  /**
39
46
  * Lazy explicit Turnstile: the third-party script loads on first form interaction (focus or
40
47
  * pointer-enter) and the widget id is stored on its form so the submit handler resets exactly
41
48
  * the token it consumed. Markup: <div class="cf-turnstile" data-sitekey="..."> inside a <form>.
49
+ *
50
+ * The widget takes the page's scheme when it renders, and Turnstile cannot change the theme of a
51
+ * rendered widget, so a switch of the site theme (hwio:themechange) redraws every widget in the
52
+ * new scheme; the redrawn widget issues a fresh token.
42
53
  */
43
54
  export function hwioTurnstileRuntime() {
44
55
  let controller = null;
45
- let widgetIds = [];
56
+ let widgets = [];
57
+ let themeBound = false;
46
58
  function cleanup() {
47
59
  controller?.abort();
48
60
  controller = null;
49
- widgetIds.forEach((id) => { try {
61
+ themeBound = false;
62
+ widgets.forEach(({ id }) => { try {
50
63
  window.turnstile?.remove(id);
51
64
  }
52
65
  catch { /* gone */ } });
53
- widgetIds = [];
66
+ widgets = [];
67
+ }
68
+ function redraw() {
69
+ const api = window.turnstile;
70
+ if (!api)
71
+ return;
72
+ widgets = widgets.filter((w) => w.container.isConnected).map((w) => {
73
+ try {
74
+ api.remove(w.id);
75
+ }
76
+ catch { /* gone */ }
77
+ const id = draw(api, w.container);
78
+ if (id === undefined)
79
+ return w;
80
+ storeId(w.form, id);
81
+ return { ...w, id };
82
+ });
54
83
  }
55
84
  function bind() {
56
85
  const containers = Array.from(document.querySelectorAll('.cf-turnstile:not([data-hwio-turnstile-bound])'));
@@ -58,6 +87,10 @@ export function hwioTurnstileRuntime() {
58
87
  return;
59
88
  controller ??= new AbortController();
60
89
  const { signal } = controller;
90
+ if (!themeBound) {
91
+ themeBound = true;
92
+ window.addEventListener('hwio:themechange', redraw, { signal });
93
+ }
61
94
  containers.forEach((container) => {
62
95
  const form = container.closest('form');
63
96
  if (!form)
@@ -68,11 +101,10 @@ export function hwioTurnstileRuntime() {
68
101
  if (signal.aborted || !container.isConnected || state === 'rendered')
69
102
  return;
70
103
  state = 'rendered';
71
- const id = api.render(container, { sitekey: container.dataset.sitekey ?? '', theme: hwioResolvedScheme(), size: widgetSize(container) });
104
+ const id = draw(api, container);
72
105
  if (id !== undefined) {
73
- widgetIds.push(id);
74
- form.hwioTurnstileWidgetId = id;
75
- form.hwTurnstileWidgetId = id; // legacy-ok
106
+ widgets.push({ container, form, id });
107
+ storeId(form, id);
76
108
  }
77
109
  };
78
110
  const load = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubpav/hwio-web-runtime",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",