help-layer 1.1.0 → 1.2.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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @param {object} state teardown registry
3
+ * @param {object} params
4
+ * @param {HTMLElement|null} params.toggleEl the toggle (must stay reachable), or null for programmatic-only
5
+ * @param {(el: Element) => boolean} params.isLibraryNode whether a body child belongs to the library UI
6
+ */
7
+ export function isolateBackgroundFromAT(state: object, { toggleEl, isLibraryNode }: {
8
+ toggleEl: HTMLElement | null;
9
+ isLibraryNode: (el: Element) => boolean;
10
+ }): void;
@@ -35,15 +35,23 @@ export function makeVirtualElement(getDocRect: () => {
35
35
  * @param {Element|object} reference
36
36
  */
37
37
  export function isFixedReference(reference: Element | object): boolean;
38
+ /**
39
+ * Whether a reference element is currently not rendered (hidden). Free placements use a virtual
40
+ * element with no host node, so they are never "hidden" (return false).
41
+ * @param {Element|object} reference
42
+ */
43
+ export function isReferenceHidden(reference: Element | object): boolean;
38
44
  /**
39
45
  * Overlap the marker onto a corner of the target (element or virtual element), stick it there, and keep it following.
40
46
  * @param {Element|object} reference
41
47
  * @param {HTMLElement} markerEl
42
48
  * @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
43
49
  * @param {import('@floating-ui/dom').Placement} [placement] corner to overlap (top-end/top-start/bottom-end/bottom-start). Default 'top-end'
50
+ * @param {() => void} [onHidden] called once each time the target transitions from visible to hidden
51
+ * (lets the caller close a popup that was open on this now-hidden marker)
44
52
  * @returns {() => void} cleanup
45
53
  */
46
- export function anchorMarker(reference: Element | object, markerEl: HTMLElement, onPlaced?: () => void, placement?: import("@floating-ui/dom").Placement): () => void;
54
+ export function anchorMarker(reference: Element | object, markerEl: HTMLElement, onPlaced?: () => void, placement?: import("@floating-ui/dom").Placement, onHidden?: () => void): () => void;
47
55
  /**
48
56
  * Place the popup below the target, and at screen edges use flip (flip to the opposite side) /
49
57
  * shift (nudge) to avoid clipping. Only follows while visible.
@@ -3,12 +3,15 @@
3
3
  * @param {object} options
4
4
  * @param {(record: import('./matcher.js').HelpRecord, markerEl: HTMLElement) => void} options.onMarkerClick
5
5
  * @param {() => void} [options.onOverlapResolved]
6
+ * @param {(record: import('./matcher.js').HelpRecord) => void} [options.onMarkerHidden] called when a
7
+ * marker's target transitions to hidden (e.g. display:none) — lets the caller close a popup open on it
6
8
  * @param {string} [options.markerLabel] character shown on the marker (default '?')
7
9
  * @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
8
10
  */
9
- export function createMarkerManager(state: object, { onMarkerClick, onOverlapResolved, markerLabel, markerPlacement, }: {
11
+ export function createMarkerManager(state: object, { onMarkerClick, onOverlapResolved, onMarkerHidden, markerLabel, markerPlacement, }: {
10
12
  onMarkerClick: (record: import("./matcher.js").HelpRecord, markerEl: HTMLElement) => void;
11
13
  onOverlapResolved?: () => void;
14
+ onMarkerHidden?: (record: import("./matcher.js").HelpRecord) => void;
12
15
  markerLabel?: string;
13
16
  markerPlacement?: import("@floating-ui/dom").Placement;
14
17
  }): {
package/package.json CHANGED
@@ -1,18 +1,19 @@
1
1
  {
2
2
  "name": "help-layer",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "engines": {
7
7
  "node": ">=18"
8
8
  },
9
- "main": "src/index.js",
9
+ "main": "dist/help-layer.esm.js",
10
+ "module": "dist/help-layer.esm.js",
10
11
  "unpkg": "dist/help-layer.iife.js",
11
12
  "types": "dist/types/index.d.ts",
12
13
  "exports": {
13
14
  ".": {
14
15
  "types": "./dist/types/index.d.ts",
15
- "import": "./src/index.js"
16
+ "import": "./dist/help-layer.esm.js"
16
17
  },
17
18
  "./dist/*": "./dist/*"
18
19
  },
@@ -38,7 +39,7 @@
38
39
  "build:types": "tsc -p jsconfig.json --declaration --emitDeclarationOnly --noEmit false --rootDir src --outDir dist/types",
39
40
  "build": "npm run build:bundle && npm run build:types",
40
41
  "record:gif": "npm run build:demos && node scripts/record-demo.js",
41
- "prepublishOnly": "npm run build",
42
+ "prepack": "npm run build",
42
43
  "demo": "npm run build:demos && node scripts/serve.js"
43
44
  },
44
45
  "devDependencies": {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Semantic background blocking for assistive technology (AT).
3
+ *
4
+ * The clip-path layer / focus containment / key suppression block pointer, physical focus, and
5
+ * physical keys, but a screen reader's virtual cursor (browse mode) reads and can activate background
6
+ * content regardless of focus or hit-testing. So while ON we also remove the host from the
7
+ * accessibility tree with `inert` (which both excludes from the a11y tree and suppresses interaction),
8
+ * giving AT users the same "host is inactive" guarantee.
9
+ *
10
+ * Why operate at document.body's top level: `inert` is inherited and cannot be cancelled on a
11
+ * descendant of an inert subtree. The toggle is host-owned and may be deeply nested, so—exactly like
12
+ * the clip-path "hole" that lets the toggle show through—we inert each body child that is neither a
13
+ * library node nor the branch containing the toggle. The toggle's own top-level branch stays
14
+ * reachable (a bounded leak when the toggle is nested; none when it's a direct body child).
15
+ */
16
+
17
+ const ELEMENT_NODE = 1;
18
+
19
+ /**
20
+ * @param {object} state teardown registry
21
+ * @param {object} params
22
+ * @param {HTMLElement|null} params.toggleEl the toggle (must stay reachable), or null for programmatic-only
23
+ * @param {(el: Element) => boolean} params.isLibraryNode whether a body child belongs to the library UI
24
+ */
25
+ export function isolateBackgroundFromAT(state, { toggleEl, isLibraryNode }) {
26
+ /** @type {Set<Element>} */
27
+ const isolated = new Set();
28
+
29
+ /** @param {Node} node */
30
+ function isolate(node) {
31
+ if (node.nodeType !== ELEMENT_NODE) {
32
+ return;
33
+ }
34
+ const el = /** @type {Element} */ (node);
35
+ // Skip library UI, the toggle's branch, and anything the host already made inert (so restore
36
+ // doesn't clobber the host's own inert state).
37
+ if (
38
+ isLibraryNode(el) ||
39
+ (toggleEl && (el === toggleEl || el.contains(toggleEl))) ||
40
+ el.hasAttribute('inert')
41
+ ) {
42
+ return;
43
+ }
44
+ el.toggleAttribute('inert', true);
45
+ isolated.add(el);
46
+ }
47
+
48
+ for (const child of [...document.body.children]) {
49
+ isolate(child);
50
+ }
51
+
52
+ // The host may add top-level nodes while ON (SPA route changes, portals, ...). Keep them isolated
53
+ // too. Only direct body children matter here, so childList without subtree is enough.
54
+ const observer = new MutationObserver((records) => {
55
+ for (const record of records) {
56
+ record.addedNodes.forEach(isolate);
57
+ }
58
+ });
59
+ observer.observe(document.body, { childList: true });
60
+
61
+ state.track(() => {
62
+ observer.disconnect();
63
+ // Remove inert only from the nodes we added it to (leave any host-owned inert untouched).
64
+ isolated.forEach((el) => el.removeAttribute('inert'));
65
+ isolated.clear();
66
+ });
67
+ }
@@ -7,7 +7,10 @@
7
7
  * - The popup uses role="dialog" + aria-labelledby (the title element) to describe itself to assistive tech.
8
8
  */
9
9
 
10
- const POPUP_TITLE_ID = 'help-layer-popup-title';
10
+ // Each initHelpLayer instance builds its own popup; a fixed id would collide when the
11
+ // library is initialized more than once on a page (invalid duplicate id + ambiguous
12
+ // aria-labelledby). Hand out a unique id per popup instead.
13
+ let popupSeq = 0;
11
14
 
12
15
  export function createBlockingLayer() {
13
16
  const layer = document.createElement('div');
@@ -33,15 +36,21 @@ export function createMarker(title, label = '?') {
33
36
  * Also returns references to titleEl/textEl (used to update the content) and the close button closeEl.
34
37
  */
35
38
  export function createPopup() {
39
+ const titleId = `help-layer-popup-title-${popupSeq++}`;
40
+
36
41
  const root = document.createElement('div');
37
42
  root.className = 'help-layer-popup';
38
43
  root.setAttribute('role', 'dialog');
39
- root.setAttribute('aria-labelledby', POPUP_TITLE_ID);
44
+ // aria-modal tells AT that content outside the dialog is inert while it's shown (the host is also
45
+ // inert'd at the document level during help mode). Harmless when hidden: display:none drops the
46
+ // popup from the a11y tree.
47
+ root.setAttribute('aria-modal', 'true');
48
+ root.setAttribute('aria-labelledby', titleId);
40
49
  root.tabIndex = -1;
41
50
 
42
51
  const titleEl = document.createElement('div');
43
52
  titleEl.className = 'help-layer-popup__title';
44
- titleEl.id = POPUP_TITLE_ID;
53
+ titleEl.id = titleId;
45
54
 
46
55
  const textEl = document.createElement('div');
47
56
  textEl.className = 'help-layer-popup__text';
package/src/floating.js CHANGED
@@ -71,6 +71,27 @@ export function isFixedReference(reference) {
71
71
  return false;
72
72
  }
73
73
 
74
+ /**
75
+ * Whether a reference element is currently not rendered (hidden). Free placements use a virtual
76
+ * element with no host node, so they are never "hidden" (return false).
77
+ * @param {Element|object} reference
78
+ */
79
+ export function isReferenceHidden(reference) {
80
+ if (!(reference instanceof Element)) {
81
+ return false;
82
+ }
83
+ // checkVisibility() catches display:none (incl. an ancestor), content-visibility, and—with the
84
+ // option—visibility:hidden, in one cheap call without any extra observers. NOTE: do NOT use
85
+ // offsetParent here; it is null for position:fixed elements too (which this lib supports as
86
+ // targets) and would wrongly hide their markers. Engines without checkVisibility fall back to the
87
+ // rect: a display:none element measures 0x0 (the worst case — the marker would jump to 0,0).
88
+ if (typeof reference.checkVisibility === 'function') {
89
+ return !reference.checkVisibility({ visibilityProperty: true, contentVisibilityAuto: true });
90
+ }
91
+ const r = reference.getBoundingClientRect();
92
+ return r.width === 0 && r.height === 0;
93
+ }
94
+
74
95
  // Half of the default marker size (22px). The amount used to overlap the marker onto the
75
96
  // target's corner with an "inset". (If the marker-size CSS variable is changed, the resulting
76
97
  // drift is left as existing behavior = not compensated for here.)
@@ -94,9 +115,11 @@ function markerOffset(placement) {
94
115
  * @param {HTMLElement} markerEl
95
116
  * @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
96
117
  * @param {import('@floating-ui/dom').Placement} [placement] corner to overlap (top-end/top-start/bottom-end/bottom-start). Default 'top-end'
118
+ * @param {() => void} [onHidden] called once each time the target transitions from visible to hidden
119
+ * (lets the caller close a popup that was open on this now-hidden marker)
97
120
  * @returns {() => void} cleanup
98
121
  */
99
- export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end') {
122
+ export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end', onHidden) {
100
123
  // Match the floating element's strategy to the reference: a fixed reference needs a fixed marker, or
101
124
  // it jitters while scrolling (see isFixedReference). Inline !important beats the stylesheet's
102
125
  // `position: absolute !important`.
@@ -104,7 +127,27 @@ export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end
104
127
  if (strategy === 'fixed') {
105
128
  markerEl.style.setProperty('position', 'fixed', 'important');
106
129
  }
130
+ // Track the visible/hidden state so onHidden fires only on the transition, not every frame.
131
+ let hiddenNow = false;
107
132
  const update = () => {
133
+ if (isReferenceHidden(reference)) {
134
+ // Target went hidden (e.g. display:none) while ON. Hide the marker too instead of leaving it
135
+ // stranded (a display:none target measures 0x0, which would otherwise fling the marker to 0,0).
136
+ // Inline !important beats the stylesheet's `display:block !important`; skip computePosition while
137
+ // hidden. autoUpdate({animationFrame:true}) keeps polling, so the marker is restored on reshow.
138
+ markerEl.style.setProperty('display', 'none', 'important');
139
+ if (!hiddenNow && onHidden) {
140
+ onHidden(); // notify on the visible -> hidden edge (e.g. close a popup open on this marker)
141
+ }
142
+ hiddenNow = true;
143
+ if (onPlaced) {
144
+ onPlaced(); // let the overlap pass re-pack without this now-hidden marker
145
+ }
146
+ return;
147
+ }
148
+ hiddenNow = false;
149
+ // Revert to the stylesheet's `display:block` (no-op when we never hid it).
150
+ markerEl.style.removeProperty('display');
108
151
  computePosition(reference, markerEl, {
109
152
  placement,
110
153
  strategy,
package/src/markers.js CHANGED
@@ -34,12 +34,15 @@ function referenceFor(record) {
34
34
  * @param {object} options
35
35
  * @param {(record: import('./matcher.js').HelpRecord, markerEl: HTMLElement) => void} options.onMarkerClick
36
36
  * @param {() => void} [options.onOverlapResolved]
37
+ * @param {(record: import('./matcher.js').HelpRecord) => void} [options.onMarkerHidden] called when a
38
+ * marker's target transitions to hidden (e.g. display:none) — lets the caller close a popup open on it
37
39
  * @param {string} [options.markerLabel] character shown on the marker (default '?')
38
40
  * @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
39
41
  */
40
42
  export function createMarkerManager(state, {
41
43
  onMarkerClick,
42
44
  onOverlapResolved,
45
+ onMarkerHidden,
43
46
  markerLabel = '?',
44
47
  markerPlacement = 'top-end',
45
48
  }) {
@@ -51,7 +54,10 @@ export function createMarkerManager(state, {
51
54
 
52
55
  function runOverlapPass() {
53
56
  rafId = null;
54
- const entries = [...markers.values()];
57
+ // Exclude hidden markers (floating.js sets inline display:none when a target goes display:none):
58
+ // such a marker measures 0x0 at the origin, so leaving it in would make it count toward the
59
+ // "<=1, skip" check and push visible markers away from (0,0).
60
+ const entries = [...markers.values()].filter((e) => e.el.style.display !== 'none');
55
61
  // With one marker or fewer, overlap is impossible. Skip getBoundingClientRect (forced reflow)
56
62
  // and the O(n^2) push-out math entirely (avoids a per-frame reflow while scrolling on screens
57
63
  // with few targets). However, right after dropping from 2 to 1, if the remaining one still has
@@ -105,7 +111,13 @@ export function createMarkerManager(state, {
105
111
  const handleClick = () => onMarkerClick(record, el);
106
112
  el.addEventListener('click', handleClick);
107
113
 
108
- const cleanupAnchor = anchorMarker(referenceFor(record), el, scheduleOverlapPass, markerPlacement);
114
+ const cleanupAnchor = anchorMarker(
115
+ referenceFor(record),
116
+ el,
117
+ scheduleOverlapPass,
118
+ markerPlacement,
119
+ () => onMarkerHidden && onMarkerHidden(record),
120
+ );
109
121
 
110
122
  // Target-element highlight (element-bound only; free placement has no target, so skip).
111
123
  // Show an outline on the target only while the marker is hovered/focused, to make clear "which element this explains".
package/src/toggle.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Starts each subsystem (style injection, marker manager, popup, blocking layer, DOM observation)
4
4
  * and aggregates their teardown into the cleanup registry (state).
5
5
  */
6
+ import { isolateBackgroundFromAT } from './aria-isolation.js';
6
7
  import { activateBlockingLayer } from './blocking-layer.js';
7
8
  import { isPlainObject, normalizeConfig, validateConfig } from './config.js';
8
9
  import { createMarkerManager } from './markers.js';
@@ -120,6 +121,14 @@ export function createToggleController(options) {
120
121
  },
121
122
  // When overlap avoidance moves a marker, make the open popup follow.
122
123
  onOverlapResolved: () => popup.reposition(),
124
+ // If a target is hidden (e.g. display:none) while its popup is open, close it (its marker just
125
+ // collapsed to 0x0) — same as the SPA-removal path. Return focus to the toggle since the marker
126
+ // is no longer focusable.
127
+ onMarkerHidden: (record) => {
128
+ if (popup.isOpen(record.id)) {
129
+ popup.close(toggleEl ?? undefined);
130
+ }
131
+ },
123
132
  });
124
133
 
125
134
  // Initial mount (free placements + elements currently in the DOM, including Shadow DOM)
@@ -151,7 +160,7 @@ export function createToggleController(options) {
151
160
  popup.root.contains(target) ||
152
161
  (typeof target.closest === 'function' && !!target.closest('.help-layer-marker')));
153
162
 
154
- activateBlockingLayer(state, {
163
+ const layer = activateBlockingLayer(state, {
155
164
  toggleEl,
156
165
  onBackgroundClick: () => popup.close(),
157
166
  isLibraryElement,
@@ -163,6 +172,15 @@ export function createToggleController(options) {
163
172
  }
164
173
  },
165
174
  });
175
+
176
+ // Semantic blocking for assistive tech: remove the host from the a11y tree while ON (the layer/
177
+ // popup/markers and the toggle stay reachable). Runs last so the just-mounted library nodes are
178
+ // present and skipped by the initial scan.
179
+ const isLibraryNode = (el) =>
180
+ el === layer ||
181
+ el === popup.root ||
182
+ (!!el.classList && el.classList.contains('help-layer-marker'));
183
+ isolateBackgroundFromAT(state, { toggleEl, isLibraryNode });
166
184
  }
167
185
 
168
186
  function turnOff() {