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.
- package/README.ja.md +141 -8
- package/README.md +150 -8
- package/dist/help-layer.esm.js +7 -5
- package/dist/help-layer.esm.js.map +4 -4
- package/dist/help-layer.iife.js +6 -4
- package/dist/help-layer.iife.js.map +4 -4
- package/dist/types/aria-isolation.d.ts +10 -0
- package/dist/types/floating.d.ts +21 -1
- package/dist/types/markers.d.ts +4 -1
- package/package.json +11 -5
- package/src/aria-isolation.js +67 -0
- package/src/dom-builder.js +12 -3
- package/src/floating.js +89 -1
- package/src/markers.js +14 -2
- package/src/style.js +3 -1
- package/src/toggle.js +19 -1
|
@@ -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;
|
package/dist/types/floating.d.ts
CHANGED
|
@@ -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.
|
package/dist/types/markers.d.ts
CHANGED
|
@@ -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
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=18"
|
|
8
8
|
},
|
|
9
|
-
"main": "
|
|
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": "./
|
|
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
|
-
"
|
|
39
|
-
"
|
|
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
|
+
}
|
package/src/dom-builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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() {
|