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/README.ja.md +101 -10
- package/README.md +106 -10
- package/dist/help-layer.esm.js +8 -8
- package/dist/help-layer.esm.js.map +4 -4
- package/dist/help-layer.iife.js +9 -9
- package/dist/help-layer.iife.js.map +4 -4
- package/dist/types/aria-isolation.d.ts +10 -0
- package/dist/types/floating.d.ts +1 -70
- package/dist/types/floating.floatingui.d.ts +25 -0
- package/dist/types/floating.self.d.ts +26 -0
- package/dist/types/geometry.d.ts +85 -4
- package/dist/types/index.d.ts +4 -4
- package/dist/types/markers.d.ts +42 -4
- package/dist/types/overlap.d.ts +2 -2
- package/dist/types/popup.d.ts +2 -2
- package/dist/types/reference.d.ts +41 -0
- package/dist/types/toggle.d.ts +4 -4
- package/dist/types/types.d.ts +7 -0
- package/package.json +10 -13
- package/src/aria-isolation.js +67 -0
- package/src/blocking-layer.js +1 -1
- package/src/dom-builder.js +22 -4
- package/src/floating.floatingui.js +71 -0
- package/src/floating.js +8 -167
- package/src/floating.self.js +109 -0
- package/src/geometry.js +168 -4
- package/src/index.js +2 -2
- package/src/markers.js +168 -43
- package/src/overlap.js +2 -2
- package/src/popup.js +40 -7
- package/src/reference.js +74 -0
- package/src/style.js +6 -6
- package/src/toggle.js +21 -3
- package/src/types.js +17 -0
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
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('
|
|
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
|
-
/**
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
248
|
+
ensureLoop(); // keep the loop alive so the next frame re-packs the remaining markers
|
|
140
249
|
};
|
|
141
250
|
|
|
142
|
-
markers.set(record.id, {
|
|
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
|
|
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)
|
|
4
|
-
* clipping. While visible it follows
|
|
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('
|
|
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
|
-
//
|
|
68
|
-
//
|
|
69
|
-
|
|
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
|
|
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');
|
package/src/reference.js
ADDED
|
@@ -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
|
|
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,
|
|
62
|
-
height: var(--help-layer-marker-size,
|
|
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,
|
|
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:
|
|
140
|
-
height:
|
|
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('
|
|
52
|
-
* @param {import('
|
|
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 {};
|