help-layer 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +62 -3
- package/README.md +65 -3
- package/dist/help-layer.esm.js +3 -3
- package/dist/help-layer.esm.js.map +4 -4
- package/dist/help-layer.iife.js +3 -3
- package/dist/help-layer.iife.js.map +4 -4
- package/dist/types/aria-isolation.d.ts +10 -0
- package/dist/types/floating.d.ts +9 -1
- package/dist/types/markers.d.ts +4 -1
- package/package.json +5 -4
- package/src/aria-isolation.js +67 -0
- package/src/dom-builder.js +12 -3
- package/src/floating.js +44 -1
- package/src/markers.js +14 -2
- 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
|
@@ -35,15 +35,23 @@ export function makeVirtualElement(getDocRect: () => {
|
|
|
35
35
|
* @param {Element|object} reference
|
|
36
36
|
*/
|
|
37
37
|
export function isFixedReference(reference: Element | object): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a reference element is currently not rendered (hidden). Free placements use a virtual
|
|
40
|
+
* element with no host node, so they are never "hidden" (return false).
|
|
41
|
+
* @param {Element|object} reference
|
|
42
|
+
*/
|
|
43
|
+
export function isReferenceHidden(reference: Element | object): boolean;
|
|
38
44
|
/**
|
|
39
45
|
* Overlap the marker onto a corner of the target (element or virtual element), stick it there, and keep it following.
|
|
40
46
|
* @param {Element|object} reference
|
|
41
47
|
* @param {HTMLElement} markerEl
|
|
42
48
|
* @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
|
|
43
49
|
* @param {import('@floating-ui/dom').Placement} [placement] corner to overlap (top-end/top-start/bottom-end/bottom-start). Default 'top-end'
|
|
50
|
+
* @param {() => void} [onHidden] called once each time the target transitions from visible to hidden
|
|
51
|
+
* (lets the caller close a popup that was open on this now-hidden marker)
|
|
44
52
|
* @returns {() => void} cleanup
|
|
45
53
|
*/
|
|
46
|
-
export function anchorMarker(reference: Element | object, markerEl: HTMLElement, onPlaced?: () => void, placement?: import("@floating-ui/dom").Placement): () => void;
|
|
54
|
+
export function anchorMarker(reference: Element | object, markerEl: HTMLElement, onPlaced?: () => void, placement?: import("@floating-ui/dom").Placement, onHidden?: () => void): () => void;
|
|
47
55
|
/**
|
|
48
56
|
* Place the popup below the target, and at screen edges use flip (flip to the opposite side) /
|
|
49
57
|
* shift (nudge) to avoid clipping. Only follows while visible.
|
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.
|
|
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
|
},
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"build:types": "tsc -p jsconfig.json --declaration --emitDeclarationOnly --noEmit false --rootDir src --outDir dist/types",
|
|
39
40
|
"build": "npm run build:bundle && npm run build:types",
|
|
40
41
|
"record:gif": "npm run build:demos && node scripts/record-demo.js",
|
|
41
|
-
"
|
|
42
|
+
"prepack": "npm run build",
|
|
42
43
|
"demo": "npm run build:demos && node scripts/serve.js"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic background blocking for assistive technology (AT).
|
|
3
|
+
*
|
|
4
|
+
* The clip-path layer / focus containment / key suppression block pointer, physical focus, and
|
|
5
|
+
* physical keys, but a screen reader's virtual cursor (browse mode) reads and can activate background
|
|
6
|
+
* content regardless of focus or hit-testing. So while ON we also remove the host from the
|
|
7
|
+
* accessibility tree with `inert` (which both excludes from the a11y tree and suppresses interaction),
|
|
8
|
+
* giving AT users the same "host is inactive" guarantee.
|
|
9
|
+
*
|
|
10
|
+
* Why operate at document.body's top level: `inert` is inherited and cannot be cancelled on a
|
|
11
|
+
* descendant of an inert subtree. The toggle is host-owned and may be deeply nested, so—exactly like
|
|
12
|
+
* the clip-path "hole" that lets the toggle show through—we inert each body child that is neither a
|
|
13
|
+
* library node nor the branch containing the toggle. The toggle's own top-level branch stays
|
|
14
|
+
* reachable (a bounded leak when the toggle is nested; none when it's a direct body child).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ELEMENT_NODE = 1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} state teardown registry
|
|
21
|
+
* @param {object} params
|
|
22
|
+
* @param {HTMLElement|null} params.toggleEl the toggle (must stay reachable), or null for programmatic-only
|
|
23
|
+
* @param {(el: Element) => boolean} params.isLibraryNode whether a body child belongs to the library UI
|
|
24
|
+
*/
|
|
25
|
+
export function isolateBackgroundFromAT(state, { toggleEl, isLibraryNode }) {
|
|
26
|
+
/** @type {Set<Element>} */
|
|
27
|
+
const isolated = new Set();
|
|
28
|
+
|
|
29
|
+
/** @param {Node} node */
|
|
30
|
+
function isolate(node) {
|
|
31
|
+
if (node.nodeType !== ELEMENT_NODE) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const el = /** @type {Element} */ (node);
|
|
35
|
+
// Skip library UI, the toggle's branch, and anything the host already made inert (so restore
|
|
36
|
+
// doesn't clobber the host's own inert state).
|
|
37
|
+
if (
|
|
38
|
+
isLibraryNode(el) ||
|
|
39
|
+
(toggleEl && (el === toggleEl || el.contains(toggleEl))) ||
|
|
40
|
+
el.hasAttribute('inert')
|
|
41
|
+
) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
el.toggleAttribute('inert', true);
|
|
45
|
+
isolated.add(el);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const child of [...document.body.children]) {
|
|
49
|
+
isolate(child);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The host may add top-level nodes while ON (SPA route changes, portals, ...). Keep them isolated
|
|
53
|
+
// too. Only direct body children matter here, so childList without subtree is enough.
|
|
54
|
+
const observer = new MutationObserver((records) => {
|
|
55
|
+
for (const record of records) {
|
|
56
|
+
record.addedNodes.forEach(isolate);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
observer.observe(document.body, { childList: true });
|
|
60
|
+
|
|
61
|
+
state.track(() => {
|
|
62
|
+
observer.disconnect();
|
|
63
|
+
// Remove inert only from the nodes we added it to (leave any host-owned inert untouched).
|
|
64
|
+
isolated.forEach((el) => el.removeAttribute('inert'));
|
|
65
|
+
isolated.clear();
|
|
66
|
+
});
|
|
67
|
+
}
|
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
|
@@ -71,6 +71,27 @@ export function isFixedReference(reference) {
|
|
|
71
71
|
return false;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Whether a reference element is currently not rendered (hidden). Free placements use a virtual
|
|
76
|
+
* element with no host node, so they are never "hidden" (return false).
|
|
77
|
+
* @param {Element|object} reference
|
|
78
|
+
*/
|
|
79
|
+
export function isReferenceHidden(reference) {
|
|
80
|
+
if (!(reference instanceof Element)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
// checkVisibility() catches display:none (incl. an ancestor), content-visibility, and—with the
|
|
84
|
+
// option—visibility:hidden, in one cheap call without any extra observers. NOTE: do NOT use
|
|
85
|
+
// offsetParent here; it is null for position:fixed elements too (which this lib supports as
|
|
86
|
+
// targets) and would wrongly hide their markers. Engines without checkVisibility fall back to the
|
|
87
|
+
// rect: a display:none element measures 0x0 (the worst case — the marker would jump to 0,0).
|
|
88
|
+
if (typeof reference.checkVisibility === 'function') {
|
|
89
|
+
return !reference.checkVisibility({ visibilityProperty: true, contentVisibilityAuto: true });
|
|
90
|
+
}
|
|
91
|
+
const r = reference.getBoundingClientRect();
|
|
92
|
+
return r.width === 0 && r.height === 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
74
95
|
// Half of the default marker size (22px). The amount used to overlap the marker onto the
|
|
75
96
|
// target's corner with an "inset". (If the marker-size CSS variable is changed, the resulting
|
|
76
97
|
// drift is left as existing behavior = not compensated for here.)
|
|
@@ -94,9 +115,11 @@ function markerOffset(placement) {
|
|
|
94
115
|
* @param {HTMLElement} markerEl
|
|
95
116
|
* @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
|
|
96
117
|
* @param {import('@floating-ui/dom').Placement} [placement] corner to overlap (top-end/top-start/bottom-end/bottom-start). Default 'top-end'
|
|
118
|
+
* @param {() => void} [onHidden] called once each time the target transitions from visible to hidden
|
|
119
|
+
* (lets the caller close a popup that was open on this now-hidden marker)
|
|
97
120
|
* @returns {() => void} cleanup
|
|
98
121
|
*/
|
|
99
|
-
export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end') {
|
|
122
|
+
export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end', onHidden) {
|
|
100
123
|
// Match the floating element's strategy to the reference: a fixed reference needs a fixed marker, or
|
|
101
124
|
// it jitters while scrolling (see isFixedReference). Inline !important beats the stylesheet's
|
|
102
125
|
// `position: absolute !important`.
|
|
@@ -104,7 +127,27 @@ export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end
|
|
|
104
127
|
if (strategy === 'fixed') {
|
|
105
128
|
markerEl.style.setProperty('position', 'fixed', 'important');
|
|
106
129
|
}
|
|
130
|
+
// Track the visible/hidden state so onHidden fires only on the transition, not every frame.
|
|
131
|
+
let hiddenNow = false;
|
|
107
132
|
const update = () => {
|
|
133
|
+
if (isReferenceHidden(reference)) {
|
|
134
|
+
// Target went hidden (e.g. display:none) while ON. Hide the marker too instead of leaving it
|
|
135
|
+
// stranded (a display:none target measures 0x0, which would otherwise fling the marker to 0,0).
|
|
136
|
+
// Inline !important beats the stylesheet's `display:block !important`; skip computePosition while
|
|
137
|
+
// hidden. autoUpdate({animationFrame:true}) keeps polling, so the marker is restored on reshow.
|
|
138
|
+
markerEl.style.setProperty('display', 'none', 'important');
|
|
139
|
+
if (!hiddenNow && onHidden) {
|
|
140
|
+
onHidden(); // notify on the visible -> hidden edge (e.g. close a popup open on this marker)
|
|
141
|
+
}
|
|
142
|
+
hiddenNow = true;
|
|
143
|
+
if (onPlaced) {
|
|
144
|
+
onPlaced(); // let the overlap pass re-pack without this now-hidden marker
|
|
145
|
+
}
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
hiddenNow = false;
|
|
149
|
+
// Revert to the stylesheet's `display:block` (no-op when we never hid it).
|
|
150
|
+
markerEl.style.removeProperty('display');
|
|
108
151
|
computePosition(reference, markerEl, {
|
|
109
152
|
placement,
|
|
110
153
|
strategy,
|
package/src/markers.js
CHANGED
|
@@ -34,12 +34,15 @@ function referenceFor(record) {
|
|
|
34
34
|
* @param {object} options
|
|
35
35
|
* @param {(record: import('./matcher.js').HelpRecord, markerEl: HTMLElement) => void} options.onMarkerClick
|
|
36
36
|
* @param {() => void} [options.onOverlapResolved]
|
|
37
|
+
* @param {(record: import('./matcher.js').HelpRecord) => void} [options.onMarkerHidden] called when a
|
|
38
|
+
* marker's target transitions to hidden (e.g. display:none) — lets the caller close a popup open on it
|
|
37
39
|
* @param {string} [options.markerLabel] character shown on the marker (default '?')
|
|
38
40
|
* @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
|
|
39
41
|
*/
|
|
40
42
|
export function createMarkerManager(state, {
|
|
41
43
|
onMarkerClick,
|
|
42
44
|
onOverlapResolved,
|
|
45
|
+
onMarkerHidden,
|
|
43
46
|
markerLabel = '?',
|
|
44
47
|
markerPlacement = 'top-end',
|
|
45
48
|
}) {
|
|
@@ -51,7 +54,10 @@ export function createMarkerManager(state, {
|
|
|
51
54
|
|
|
52
55
|
function runOverlapPass() {
|
|
53
56
|
rafId = null;
|
|
54
|
-
|
|
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/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() {
|