help-layer 1.0.1 → 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;
@@ -23,15 +23,35 @@ export function makeVirtualElement(getDocRect: () => {
23
23
  height: number;
24
24
  };
25
25
  };
26
+ /**
27
+ * Whether the reference lives in a `position: fixed` subtree. Such a reference stays put in the
28
+ * viewport while the page scrolls, so an absolutely-positioned floating element (which scrolls with
29
+ * the document) would have to be re-corrected every frame and visibly jitters. For these we switch the
30
+ * floating element to Floating UI's `fixed` strategy (and position:fixed) so both live in the same
31
+ * viewport space and stay glued without per-frame correction.
32
+ *
33
+ * Virtual elements (free placements) aren't in the DOM and already track scroll via their getRect, so
34
+ * they report false. Walks across shadow boundaries via the host so Shadow DOM targets are handled too.
35
+ * @param {Element|object} reference
36
+ */
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;
26
44
  /**
27
45
  * Overlap the marker onto a corner of the target (element or virtual element), stick it there, and keep it following.
28
46
  * @param {Element|object} reference
29
47
  * @param {HTMLElement} markerEl
30
48
  * @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
31
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)
32
52
  * @returns {() => void} cleanup
33
53
  */
34
- 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;
35
55
  /**
36
56
  * Place the popup below the target, and at screen edges use flip (flip to the opposite side) /
37
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.0.1",
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
  },
@@ -32,11 +33,14 @@
32
33
  "lint:fix": "eslint src tests --fix",
33
34
  "typecheck": "tsc -p jsconfig.json",
34
35
  "check": "npm run lint && npm run typecheck && npm test",
36
+ "build:site": "node demo/build-site.js",
37
+ "build:demos": "node demo/build-framework-demos.js",
35
38
  "build:bundle": "node scripts/build.js",
36
39
  "build:types": "tsc -p jsconfig.json --declaration --emitDeclarationOnly --noEmit false --rootDir src --outDir dist/types",
37
40
  "build": "npm run build:bundle && npm run build:types",
38
- "prepublishOnly": "npm run build",
39
- "demo": "node scripts/serve.js"
41
+ "record:gif": "npm run build:demos && node scripts/record-demo.js",
42
+ "prepack": "npm run build",
43
+ "demo": "npm run build:demos && node scripts/serve.js"
40
44
  },
41
45
  "devDependencies": {
42
46
  "@playwright/test": "^1.61.0",
@@ -45,8 +49,10 @@
45
49
  "eslint-plugin-import": "^2.32.0",
46
50
  "eslint-plugin-n": "^17.24.0",
47
51
  "eslint-plugin-promise": "^7.2.1",
52
+ "gifenc": "^1.0.3",
48
53
  "jest": "^29.7.0",
49
54
  "jest-environment-jsdom": "^29.7.0",
55
+ "pngjs": "^7.0.0",
50
56
  "react": "^19.2.7",
51
57
  "react-dom": "^19.2.7",
52
58
  "typescript": "^6.0.3",
@@ -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
@@ -40,6 +40,58 @@ function place(el, x, y) {
40
40
  el.style.top = `${y}px`;
41
41
  }
42
42
 
43
+ /**
44
+ * Whether the reference lives in a `position: fixed` subtree. Such a reference stays put in the
45
+ * viewport while the page scrolls, so an absolutely-positioned floating element (which scrolls with
46
+ * the document) would have to be re-corrected every frame and visibly jitters. For these we switch the
47
+ * floating element to Floating UI's `fixed` strategy (and position:fixed) so both live in the same
48
+ * viewport space and stay glued without per-frame correction.
49
+ *
50
+ * Virtual elements (free placements) aren't in the DOM and already track scroll via their getRect, so
51
+ * they report false. Walks across shadow boundaries via the host so Shadow DOM targets are handled too.
52
+ * @param {Element|object} reference
53
+ */
54
+ export function isFixedReference(reference) {
55
+ if (!(reference instanceof Element)) {
56
+ return false;
57
+ }
58
+ let node = reference;
59
+ while (node) {
60
+ if (getComputedStyle(node).position === 'fixed') {
61
+ return true;
62
+ }
63
+ const parent = node.parentElement;
64
+ if (parent) {
65
+ node = parent;
66
+ } else {
67
+ const root = node.getRootNode();
68
+ node = root instanceof ShadowRoot ? root.host : null;
69
+ }
70
+ }
71
+ return false;
72
+ }
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
+
43
95
  // Half of the default marker size (22px). The amount used to overlap the marker onto the
44
96
  // target's corner with an "inset". (If the marker-size CSS variable is changed, the resulting
45
97
  // drift is left as existing behavior = not compensated for here.)
@@ -63,12 +115,42 @@ function markerOffset(placement) {
63
115
  * @param {HTMLElement} markerEl
64
116
  * @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
65
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)
66
120
  * @returns {() => void} cleanup
67
121
  */
68
- export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end') {
122
+ export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end', onHidden) {
123
+ // Match the floating element's strategy to the reference: a fixed reference needs a fixed marker, or
124
+ // it jitters while scrolling (see isFixedReference). Inline !important beats the stylesheet's
125
+ // `position: absolute !important`.
126
+ const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
127
+ if (strategy === 'fixed') {
128
+ markerEl.style.setProperty('position', 'fixed', 'important');
129
+ }
130
+ // Track the visible/hidden state so onHidden fires only on the transition, not every frame.
131
+ let hiddenNow = false;
69
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');
70
151
  computePosition(reference, markerEl, {
71
152
  placement,
153
+ strategy,
72
154
  middleware: [offset(markerOffset(placement))],
73
155
  }).then(({ x, y }) => {
74
156
  place(markerEl, x, y);
@@ -95,9 +177,15 @@ export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end
95
177
  * calling update repositions immediately (used for reference-side transform moves that autoUpdate doesn't pick up, etc.).
96
178
  */
97
179
  export function anchorPopup(reference, popupEl, placement = 'bottom-start') {
180
+ // The reference is the clicked marker. If it's fixed (anchored to a fixed target), the popup must be
181
+ // fixed too or it jitters on scroll. Set position every open so reopening on a normal marker restores
182
+ // absolute. Inline !important beats the stylesheet's `position: absolute !important`.
183
+ const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
184
+ popupEl.style.setProperty('position', strategy, 'important');
98
185
  const update = () => {
99
186
  computePosition(reference, popupEl, {
100
187
  placement,
188
+ strategy,
101
189
  middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
102
190
  }).then(({ x, y }) => {
103
191
  place(popupEl, x, y);
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/style.js CHANGED
@@ -48,7 +48,9 @@ const CSS = `
48
48
  border: none;
49
49
  /* Structural properties are !important so a host's broad rules (e.g. button { display:none }) can't
50
50
  hide or distort the marker. top/left stay non-important because place() writes them inline per
51
- frame; !important there would override that and pin the marker to 0,0. Theme stays var()-driven. */
51
+ frame; !important there would override that and pin the marker to 0,0. Theme stays var()-driven.
52
+ Note: for targets in a position:fixed subtree, floating.js overrides this with an inline
53
+ position:fixed !important (inline important beats this rule) so the marker doesn't jitter. */
52
54
  position: absolute !important;
53
55
  display: block !important;
54
56
  visibility: visible !important;
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() {