help-layer 1.1.0 → 1.3.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/src/markers.js CHANGED
@@ -1,21 +1,33 @@
1
1
  /**
2
2
  * Marker manager.
3
3
  * Markers can be dynamically mounted/unmounted per help record (SPA dynamic-element support).
4
- * Each marker keeps following its target (an element, or the virtual element of a free placement)
5
- * via Floating UI's autoUpdate. On every finalized placement it triggers the overlap-avoidance pass,
6
- * debounced with rAF.
4
+ *
5
+ * Positioning runs in ONE shared requestAnimationFrame loop owned by the manager (not one Floating UI
6
+ * autoUpdate per marker). Each frame the loop:
7
+ * 1. reads every visible marker's reference rect (and the shared offsetParent geometry) once,
8
+ * 2. computes each marker's corner-overlap position synchronously (markers only need an offset,
9
+ * never flip/shift — those are popup-only), runs overlap avoidance on the centers, and
10
+ * 3. writes left/top in a single batched pass.
11
+ * Folding tracking + overlap into one read-then-write loop avoids the layout thrashing and the
12
+ * doubled rect reads of running N independent animation-frame loops, which is what made large marker
13
+ * counts expensive. Smoothness is unchanged: writes still happen every frame before paint.
7
14
  *
8
15
  * Marker identifier (id):
9
16
  * - element-bound: the target element itself (distinguishes multiple elements with the same data-help-id)
10
17
  * - free placement: the config key string
11
18
  */
12
19
  import { createMarker } from './dom-builder.js';
13
- import { anchorMarker, makeVirtualElement } from './floating.js';
20
+ import { isFixedReference, isReferenceHidden, makeVirtualElement } from './floating.js';
21
+ import { markerViewportTopLeft, viewportToAbsolute } from './geometry.js';
14
22
  import { resolveOverlaps } from './overlap.js';
15
23
 
16
24
  // Temporary class added to the target element only while the marker is hovered/focused (matches the style.js definition).
17
25
  const TARGET_HIGHLIGHT_CLASS = 'help-layer-target-highlight';
18
26
 
27
+ // Fallback marker size if the real size can't be measured yet (matches the CSS default). Used only
28
+ // until a laid-out marker reports a non-zero offsetWidth, which is then cached.
29
+ const DEFAULT_MARKER_SIZE = 24;
30
+
19
31
  /** @param {import('./matcher.js').HelpRecord} record */
20
32
  function referenceFor(record) {
21
33
  if (record.kind === 'free') {
@@ -33,64 +45,155 @@ function referenceFor(record) {
33
45
  * @param {object} state teardown registry
34
46
  * @param {object} options
35
47
  * @param {(record: import('./matcher.js').HelpRecord, markerEl: HTMLElement) => void} options.onMarkerClick
36
- * @param {() => void} [options.onOverlapResolved]
48
+ * @param {() => void} [options.onOverlapResolved] called once per frame in which any marker actually moved
49
+ * @param {(record: import('./matcher.js').HelpRecord) => void} [options.onMarkerHidden] called when a
50
+ * marker's target transitions to hidden (e.g. display:none) — lets the caller close a popup open on it
37
51
  * @param {string} [options.markerLabel] character shown on the marker (default '?')
38
- * @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
52
+ * @param {import('./types.js').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
39
53
  */
40
54
  export function createMarkerManager(state, {
41
55
  onMarkerClick,
42
56
  onOverlapResolved,
57
+ onMarkerHidden,
43
58
  markerLabel = '?',
44
59
  markerPlacement = 'top-end',
45
60
  }) {
46
- /** @type {Map<Element|string, {record:import('./matcher.js').HelpRecord, el:HTMLElement, cleanup:() => void}>} */
61
+ /**
62
+ * @typedef {object} MarkerEntry
63
+ * @property {import('./matcher.js').HelpRecord} record
64
+ * @property {HTMLElement} el
65
+ * @property {Element|object} reference positioning reference (element or virtual element)
66
+ * @property {'fixed'|'absolute'} strategy positioning strategy chosen from the reference
67
+ * @property {import('./types.js').Placement} placement corner to overlap onto
68
+ * @property {() => void} cleanup
69
+ * @property {boolean} hidden whether the target is currently reported hidden (edge tracking for onMarkerHidden)
70
+ * @property {DOMRect=} refRect the reference rect read during the current frame's read phase
71
+ * @property {{left:number,top:number}|null} lastBaseEl previous frame's pre-overlap position (element space) — movement detection
72
+ * @property {number|undefined} lastLeft last written left (px), to skip redundant DOM writes
73
+ * @property {number|undefined} lastTop last written top (px)
74
+ */
75
+ /** @type {Map<Element|string, MarkerEntry>} */
47
76
  const markers = new Map();
48
77
  let rafId = null;
49
78
  // Don't schedule a new rAF during teardown (prevents a frame lingering after teardown).
50
79
  let tornDown = false;
80
+ // Cached marker size (square). Measured once from a laid-out marker; 0 until then.
81
+ let markerSize = 0;
82
+ // Visible-marker count from the previous frame, to detect membership changes (a marker entering or
83
+ // leaving the visible set means overlap must be recomputed even if no surviving marker's base moved).
84
+ let prevVisibleCount = -1;
51
85
 
52
- function runOverlapPass() {
53
- rafId = null;
54
- const entries = [...markers.values()];
55
- // With one marker or fewer, overlap is impossible. Skip getBoundingClientRect (forced reflow)
56
- // and the O(n^2) push-out math entirely (avoids a per-frame reflow while scrolling on screens
57
- // with few targets). However, right after dropping from 2 to 1, if the remaining one still has
58
- // a leftover push-out transform, clear it and let an open popup follow that move (in steady
59
- // state, with an empty transform, do nothing).
60
- if (entries.length <= 1) {
61
- const el = entries.length === 1 ? entries[0].el : null;
62
- if (el && el.style.transform) {
63
- el.style.transform = '';
64
- if (onOverlapResolved) {
65
- onOverlapResolved();
86
+ // One positioning pass: read references + offsetParent once, compute corner placements + overlap,
87
+ // then write left/top in a batch. Pure of scheduling so it can run either synchronously (initial
88
+ // placement, to avoid a one-frame flash at 0,0) or from the continuous rAF loop (tracking).
89
+ function positionAll() {
90
+ if (tornDown || markers.size === 0) {
91
+ return;
92
+ }
93
+
94
+ // --- Read phase: visibility edges + reference rects, plus the shared offsetParent geometry. ---
95
+ const bodyRect = document.body.getBoundingClientRect();
96
+ const bodyClientLeft = document.body.clientLeft;
97
+ const bodyClientTop = document.body.clientTop;
98
+
99
+ /** @type {MarkerEntry[]} */
100
+ const visible = [];
101
+ for (const entry of markers.values()) {
102
+ if (isReferenceHidden(entry.reference)) {
103
+ // Target went hidden (e.g. display:none). Hide the marker too instead of leaving it stranded
104
+ // (a display:none target measures 0x0, which would otherwise fling the marker to 0,0). Inline
105
+ // !important beats the stylesheet's `display:block !important`. Fire onMarkerHidden only on the
106
+ // visible -> hidden edge (e.g. to close a popup open on this marker).
107
+ if (!entry.hidden) {
108
+ entry.hidden = true;
109
+ entry.lastBaseEl = null; // force a fresh placement when it reshows
110
+ entry.el.style.setProperty('display', 'none', 'important');
111
+ if (onMarkerHidden) {
112
+ onMarkerHidden(entry.record);
113
+ }
66
114
  }
115
+ continue;
67
116
  }
68
- return;
117
+ if (entry.hidden) {
118
+ entry.hidden = false;
119
+ entry.el.style.removeProperty('display'); // back to the stylesheet's display:block
120
+ }
121
+ entry.refRect = entry.reference.getBoundingClientRect();
122
+ visible.push(entry);
69
123
  }
70
124
 
71
- // Clear the accumulated transform to measure base centers, then reapply after resolving overlaps.
72
- entries.forEach((e) => { e.el.style.transform = ''; });
73
- const centers = entries.map((e) => {
74
- const r = e.el.getBoundingClientRect();
75
- return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
76
- });
77
- const offsets = resolveOverlaps(centers);
78
- entries.forEach((e, i) => {
79
- const { dx, dy } = offsets[i];
80
- e.el.style.transform = (dx || dy) ? `translate(${dx}px, ${dy}px)` : '';
81
- });
125
+ // Cache the marker size once a real measurement is available (custom --help-layer-marker-size honored).
126
+ if (!markerSize && visible.length) {
127
+ const measured = visible[0].el.offsetWidth;
128
+ if (measured > 0) {
129
+ markerSize = measured;
130
+ }
131
+ }
132
+ const size = markerSize || DEFAULT_MARKER_SIZE;
82
133
 
83
- // Marker positions moved, so give an open popup etc. the chance to follow.
84
- if (onOverlapResolved) {
85
- onOverlapResolved();
134
+ // --- Compute phase (no DOM): base positions, movement/membership detection, overlap offsets. ---
135
+ let dirty = visible.length !== prevVisibleCount;
136
+ prevVisibleCount = visible.length;
137
+ /** @type {{left:number,top:number}[]} */
138
+ const bases = [];
139
+ /** @type {{x:number,y:number}[]} */
140
+ const centers = [];
141
+ for (const entry of visible) {
142
+ const bv = markerViewportTopLeft(entry.refRect, size, entry.placement);
143
+ centers.push({ x: bv.left + size / 2, y: bv.top + size / 2 });
144
+ // Convert the viewport position to what we actually write. For absolute markers this is
145
+ // scroll-invariant (refRect and bodyRect both shift with scroll), so plain page scroll produces
146
+ // no write — the marker rides the document for free. A write happens only when the target really
147
+ // moves relative to the document (layout, resize, animation).
148
+ const be = entry.strategy === 'fixed'
149
+ ? { left: bv.left, top: bv.top }
150
+ : viewportToAbsolute(bv.left, bv.top, bodyRect, bodyClientLeft, bodyClientTop);
151
+ bases.push(be);
152
+ if (!entry.lastBaseEl || entry.lastBaseEl.left !== be.left || entry.lastBaseEl.top !== be.top) {
153
+ dirty = true;
154
+ }
155
+ entry.lastBaseEl = be;
156
+ }
157
+
158
+ // --- Write phase: only when something changed, and only the markers whose position differs. ---
159
+ if (dirty && visible.length) {
160
+ const offsets = resolveOverlaps(centers);
161
+ let moved = false;
162
+ for (let i = 0; i < visible.length; i++) {
163
+ const entry = visible[i];
164
+ const left = bases[i].left + offsets[i].dx;
165
+ const top = bases[i].top + offsets[i].dy;
166
+ if (entry.lastLeft !== left || entry.lastTop !== top) {
167
+ entry.el.style.left = `${left}px`;
168
+ entry.el.style.top = `${top}px`;
169
+ entry.lastLeft = left;
170
+ entry.lastTop = top;
171
+ moved = true;
172
+ }
173
+ }
174
+ // Marker positions moved, so give an open popup etc. the chance to follow.
175
+ if (moved && onOverlapResolved) {
176
+ onOverlapResolved();
177
+ }
86
178
  }
87
179
  }
88
180
 
89
- function scheduleOverlapPass() {
90
- if (rafId !== null || tornDown) {
181
+ // Continuous tracking: position every frame, then re-schedule. Stops re-scheduling once there are no
182
+ // markers left (or after teardown); ensureLoop() restarts it on the next mount.
183
+ function frameTick() {
184
+ rafId = null;
185
+ if (tornDown || markers.size === 0) {
91
186
  return;
92
187
  }
93
- rafId = requestAnimationFrame(runOverlapPass);
188
+ positionAll();
189
+ rafId = requestAnimationFrame(frameTick);
190
+ }
191
+
192
+ function ensureLoop() {
193
+ if (rafId !== null || tornDown || markers.size === 0) {
194
+ return;
195
+ }
196
+ rafId = requestAnimationFrame(frameTick);
94
197
  }
95
198
 
96
199
  /** @param {import('./matcher.js').HelpRecord} record */
@@ -105,7 +208,14 @@ export function createMarkerManager(state, {
105
208
  const handleClick = () => onMarkerClick(record, el);
106
209
  el.addEventListener('click', handleClick);
107
210
 
108
- const cleanupAnchor = anchorMarker(referenceFor(record), el, scheduleOverlapPass, markerPlacement);
211
+ const reference = referenceFor(record);
212
+ // Match the strategy to the reference: a fixed reference needs a fixed marker, or it scrolls with
213
+ // the document while the fixed target stays put and visibly drifts (see isFixedReference). Inline
214
+ // !important beats the stylesheet's `position: absolute !important`.
215
+ const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
216
+ if (strategy === 'fixed') {
217
+ el.style.setProperty('position', 'fixed', 'important');
218
+ }
109
219
 
110
220
  // Target-element highlight (element-bound only; free placement has no target, so skip).
111
221
  // Show an outline on the target only while the marker is hovered/focused, to make clear "which element this explains".
@@ -125,7 +235,6 @@ export function createMarkerManager(state, {
125
235
  return;
126
236
  }
127
237
  done = true;
128
- cleanupAnchor();
129
238
  el.removeEventListener('click', handleClick);
130
239
  if (target) {
131
240
  el.removeEventListener('mouseenter', addHighlight);
@@ -136,10 +245,22 @@ export function createMarkerManager(state, {
136
245
  }
137
246
  el.remove();
138
247
  markers.delete(record.id);
139
- scheduleOverlapPass();
248
+ ensureLoop(); // keep the loop alive so the next frame re-packs the remaining markers
140
249
  };
141
250
 
142
- markers.set(record.id, { record, el, cleanup });
251
+ markers.set(record.id, {
252
+ record,
253
+ el,
254
+ reference,
255
+ strategy,
256
+ placement: markerPlacement,
257
+ cleanup,
258
+ hidden: false,
259
+ lastBaseEl: null,
260
+ lastLeft: undefined,
261
+ lastTop: undefined,
262
+ });
263
+ ensureLoop();
143
264
  }
144
265
 
145
266
  function unmount(id) {
@@ -151,6 +272,10 @@ export function createMarkerManager(state, {
151
272
 
152
273
  function mountAll(records) {
153
274
  records.forEach(mount);
275
+ // Place the whole batch synchronously (before paint) so markers don't flash at (0,0) for a frame
276
+ // on enable; the rAF loop started by mount() then takes over tracking. Done once per batch (not
277
+ // per mount) to keep this O(n), not O(n^2).
278
+ positionAll();
154
279
  }
155
280
 
156
281
  // Register a single teardown for the whole manager with state
package/src/overlap.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Overlap avoidance between markers (pure function).
3
3
  *
4
- * Takes an array of each marker's "base position" (the center coordinate Floating UI
5
- * decided on) and returns an array of extra offsets that push overlapping ones apart.
4
+ * Takes an array of each marker's "base position" (the center coordinate the positioning
5
+ * pass decided on) and returns an array of extra offsets that push overlapping ones apart.
6
6
  * Touches no DOM.
7
7
  *
8
8
  * The algorithm is a simple iterative push-out (a lightweight force-based separation):
package/src/popup.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The single popup shared across the whole library.
3
- * Placed on its target (the clicked marker) with Floating UI; at screen edges, flip/shift avoid
4
- * clipping. While visible it follows via autoUpdate.
3
+ * Placed on its target (the clicked marker) via the positioning seam (anchorPopup in floating.js);
4
+ * at screen edges, flip/shift avoid clipping. While visible it follows the marker per animation frame.
5
5
  *
6
6
  * Accessibility:
7
7
  * - On open, move focus to the popup (role=dialog).
@@ -19,7 +19,7 @@ import { safeInvoke } from './safe.js';
19
19
  * Escape hatch to render the body area with your own DOM node. Return a Node to display it;
20
20
  * if nothing is returned, fall back to safe text rendering (textContent). The title is always record.title.
21
21
  * Note: the return value is appendChild'd as-is without sanitization, so untrusted data must be neutralized by the caller.
22
- * @param {import('@floating-ui/dom').Placement} [options.popupPlacement] initial placement (default 'bottom-start')
22
+ * @param {import('./types.js').Placement} [options.popupPlacement] initial placement (default 'bottom-start')
23
23
  */
24
24
  export function createPopupController(state, { onClose, render, popupPlacement = 'bottom-start' } = {}) {
25
25
  const { root, titleEl, textEl, closeEl } = createPopup();
@@ -42,6 +42,34 @@ export function createPopupController(state, { onClose, render, popupPlacement =
42
42
  }
43
43
  }
44
44
 
45
+ // Focus trap. aria-modal="true" promises AT that the rest of the page is inert, but keyboard Tab
46
+ // would still escape to the markers/toggle behind the popup (they're "library elements", so the
47
+ // blocking layer lets their keys through). Keep Tab cycling inside the dialog to match the promise.
48
+ const FOCUSABLE = 'a[href],button,input,select,textarea,[tabindex]:not([tabindex="-1"])';
49
+ function trapTab(event) {
50
+ if (event.key !== 'Tab') {
51
+ return;
52
+ }
53
+ // Recompute every keypress: a custom render() can add its own focusables to the body. We don't
54
+ // filter by layout visibility here — the popup's contents are controlled (a close button plus
55
+ // whatever render returns), and a layout probe (offsetParent/getClientRects) is unreliable anyway.
56
+ const focusable = [...root.querySelectorAll(FOCUSABLE)].filter((el) => el instanceof HTMLElement);
57
+ event.preventDefault();
58
+ if (focusable.length === 0) {
59
+ // Nothing focusable inside: hold focus on the dialog itself rather than letting it escape.
60
+ root.focus({ preventScroll: true });
61
+ return;
62
+ }
63
+ const count = focusable.length;
64
+ const index = focusable.indexOf(document.activeElement instanceof HTMLElement ? document.activeElement : null);
65
+ // Step in the requested direction, wrapping at both ends. When focus is on the dialog root
66
+ // (index -1), Tab starts at the first element and Shift+Tab at the last.
67
+ const next = index === -1
68
+ ? (event.shiftKey ? focusable[count - 1] : focusable[0])
69
+ : focusable[(index + (event.shiftKey ? -1 : 1) + count) % count];
70
+ next.focus({ preventScroll: true });
71
+ }
72
+
45
73
  /**
46
74
  * @param {import('./matcher.js').HelpRecord} record
47
75
  * @param {HTMLElement} referenceEl placement reference (the clicked marker element)
@@ -64,14 +92,18 @@ export function createPopupController(state, { onClose, render, popupPlacement =
64
92
  stopAnchor();
65
93
  anchor = anchorPopup(referenceEl, root, popupPlacement);
66
94
 
67
- // preventScroll: the popup is positioned asynchronously (computePosition().then), so at this
68
- // point it's still at its stale position; a default focus would scroll toward that, causing a
69
- // visible jump. flip/shift keep it in the viewport, so suppressing the scroll is safe.
95
+ // Keep Tab inside the dialog while it's open (removed in hide()). Capture phase so it runs before
96
+ // any focusable's own keydown can act on the Tab.
97
+ root.addEventListener('keydown', trapTab, true);
98
+
99
+ // preventScroll: anchorPopup positions the popup synchronously above, so it's already in place,
100
+ // but focusing it can still nudge an ancestor scroll container toward it; flip/shift keep it in
101
+ // the viewport, so suppressing that scroll is safe and avoids a visible jump.
70
102
  root.focus({ preventScroll: true });
71
103
  }
72
104
 
73
105
  // Reposition immediately, only when open.
74
- // (Called e.g. right after a marker shifts due to the overlap-avoidance transform.)
106
+ // (Called e.g. right after a marker's left/top shifts from the overlap-avoidance pass.)
75
107
  function reposition() {
76
108
  if (anchor) {
77
109
  anchor.update();
@@ -82,6 +114,7 @@ export function createPopupController(state, { onClose, render, popupPlacement =
82
114
  // Call onClose only if it was open (catches both the close-path and teardown-path close routes at one point).
83
115
  const wasOpen = openId !== null;
84
116
  stopAnchor();
117
+ root.removeEventListener('keydown', trapTab, true);
85
118
  openId = null;
86
119
  triggerEl = null;
87
120
  root.style.setProperty('display', 'none', 'important');
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Backend-agnostic helpers about a positioning "reference" (the element a marker/popup points at,
3
+ * or a virtual element for free placements). These touch only the DOM — no positioning library — so
4
+ * both the self-implemented and the Floating UI positioning backends share them unchanged.
5
+ */
6
+ import { docRectToViewportRect } from './geometry.js';
7
+
8
+ /**
9
+ * Create a "virtual reference element" for free-placement items not bound to an element.
10
+ * getDocRect() returns document coordinates; this converts them to viewport coordinates for the
11
+ * current scroll, so the element tracks the page as it scrolls (it's re-read every frame).
12
+ * @param {() => {top:number,left:number,width?:number,height?:number}} getDocRect
13
+ */
14
+ export function makeVirtualElement(getDocRect) {
15
+ return {
16
+ // Kept for parity with the Floating UI backend (its autoUpdate uses contextElement to know which
17
+ // scroll ancestors to watch). Harmless for the self backend, which reads the rect directly.
18
+ contextElement: document.body,
19
+ getBoundingClientRect() {
20
+ return docRectToViewportRect(getDocRect(), { x: window.scrollX, y: window.scrollY });
21
+ },
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Whether the reference lives in a `position: fixed` subtree. Such a reference stays put in the
27
+ * viewport while the page scrolls, so an absolutely-positioned floating element (which scrolls with
28
+ * the document) would drift; for these we switch the floating element to a fixed strategy so both
29
+ * live in the same viewport space and stay glued without per-frame correction.
30
+ *
31
+ * Virtual elements (free placements) aren't in the DOM and already track scroll via their getRect, so
32
+ * they report false. Walks across shadow boundaries via the host so Shadow DOM targets are handled too.
33
+ * @param {Element|object} reference
34
+ */
35
+ export function isFixedReference(reference) {
36
+ if (!(reference instanceof Element)) {
37
+ return false;
38
+ }
39
+ let node = reference;
40
+ while (node) {
41
+ if (getComputedStyle(node).position === 'fixed') {
42
+ return true;
43
+ }
44
+ const parent = node.parentElement;
45
+ if (parent) {
46
+ node = parent;
47
+ } else {
48
+ const root = node.getRootNode();
49
+ node = root instanceof ShadowRoot ? root.host : null;
50
+ }
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /**
56
+ * Whether a reference element is currently not rendered (hidden). Free placements use a virtual
57
+ * element with no host node, so they are never "hidden" (return false).
58
+ * @param {Element|object} reference
59
+ */
60
+ export function isReferenceHidden(reference) {
61
+ if (!(reference instanceof Element)) {
62
+ return false;
63
+ }
64
+ // checkVisibility() catches display:none (incl. an ancestor), content-visibility, and—with the
65
+ // option—visibility:hidden, in one cheap call without any extra observers. NOTE: do NOT use
66
+ // offsetParent here; it is null for position:fixed elements too (which this lib supports as
67
+ // targets) and would wrongly hide their markers. Engines without checkVisibility fall back to the
68
+ // rect: a display:none element measures 0x0 (the worst case — the marker would jump to 0,0).
69
+ if (typeof reference.checkVisibility === 'function') {
70
+ return !reference.checkVisibility({ visibilityProperty: true, contentVisibilityAuto: true });
71
+ }
72
+ const r = reference.getBoundingClientRect();
73
+ return r.width === 0 && r.height === 0;
74
+ }
package/src/style.js CHANGED
@@ -13,7 +13,7 @@ const STYLE_ATTR = 'data-help-layer-style';
13
13
 
14
14
  // The theme is fully exposed via CSS custom properties. Users can change the look just by
15
15
  // overriding the following variables in host-side CSS (e.g. :root or any scope):
16
- // --help-layer-marker-size marker diameter (default 22px)
16
+ // --help-layer-marker-size marker diameter (default 24px, WCAG 2.5.8 minimum target size)
17
17
  // --help-layer-marker-bg marker background color (default #2563eb)
18
18
  // --help-layer-marker-color marker text color (default #fff)
19
19
  // --help-layer-popup-bg popup background color (default #fff)
@@ -58,15 +58,15 @@ const CSS = `
58
58
  pointer-events: auto !important;
59
59
  top: 0;
60
60
  left: 0;
61
- width: var(--help-layer-marker-size, 22px) !important;
62
- height: var(--help-layer-marker-size, 22px) !important;
61
+ width: var(--help-layer-marker-size, 24px) !important;
62
+ height: var(--help-layer-marker-size, 24px) !important;
63
63
  border-radius: 50%;
64
64
  background: var(--help-layer-marker-bg, #2563eb);
65
65
  color: var(--help-layer-marker-color, #fff);
66
66
  font-family: sans-serif;
67
67
  font-size: 13px;
68
68
  font-weight: bold;
69
- line-height: var(--help-layer-marker-size, 22px);
69
+ line-height: var(--help-layer-marker-size, 24px);
70
70
  text-align: center;
71
71
  cursor: pointer;
72
72
  user-select: none;
@@ -136,8 +136,8 @@ const CSS = `
136
136
  pointer-events: auto !important;
137
137
  top: 6px;
138
138
  right: 6px;
139
- width: 22px;
140
- height: 22px;
139
+ width: 24px;
140
+ height: 24px;
141
141
  padding: 0;
142
142
  border: none;
143
143
  border-radius: 4px;
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';
@@ -48,8 +49,8 @@ function resolveToggleElement(toggle) {
48
49
  * @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render] render the popup body with your own Node
49
50
  * (the return value is inserted as-is without sanitization, so untrusted data must be neutralized by the caller)
50
51
  * @param {string} [options.markerLabel] character shown on the marker (default '?')
51
- * @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap the marker onto (default 'top-end')
52
- * @param {import('@floating-ui/dom').Placement} [options.popupPlacement] initial popup placement (default 'bottom-start')
52
+ * @param {import('./types.js').Placement} [options.markerPlacement] corner to overlap the marker onto (default 'top-end')
53
+ * @param {import('./types.js').Placement} [options.popupPlacement] initial popup placement (default 'bottom-start')
53
54
  * @param {string} [options.nonce] nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
54
55
  */
55
56
  export function createToggleController(options) {
@@ -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() {
package/src/types.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Shared type definitions (JSDoc only — no runtime code).
3
+ *
4
+ * `Placement` mirrors the placement strings Floating UI accepts, defined locally so the library (and
5
+ * its generated .d.ts) carry no dependency on @floating-ui/dom. A placement is a side, optionally
6
+ * suffixed with an alignment: `top` / `top-start` / `top-end` / ... for the four sides.
7
+ *
8
+ * @typedef {(
9
+ * 'top' | 'top-start' | 'top-end' |
10
+ * 'right' | 'right-start' | 'right-end' |
11
+ * 'bottom' | 'bottom-start' | 'bottom-end' |
12
+ * 'left' | 'left-start' | 'left-end'
13
+ * )} Placement
14
+ */
15
+
16
+ // No runtime exports; this module exists solely to host the typedefs above.
17
+ export {};