help-layer 1.0.0 → 1.1.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 +82 -8
- package/README.md +88 -8
- package/dist/help-layer.esm.js +35 -14
- package/dist/help-layer.esm.js.map +4 -4
- package/dist/help-layer.iife.js +34 -13
- package/dist/help-layer.iife.js.map +4 -4
- package/dist/types/config.d.ts +15 -0
- package/dist/types/floating.d.ts +12 -0
- package/dist/types/safe.d.ts +17 -0
- package/dist/types/toggle.d.ts +15 -15
- package/package.json +8 -3
- package/src/config.js +6 -4
- package/src/floating.js +50 -2
- package/src/observer.js +6 -2
- package/src/popup.js +10 -5
- package/src/safe.js +29 -0
- package/src/state.js +7 -1
- package/src/style.js +31 -10
- package/src/toggle.js +54 -46
package/dist/types/config.d.ts
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation and normalization of the helpConfig object. A pure function that does no DOM work.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {object} HelpEntry
|
|
6
|
+
* @property {string} title heading shown on the marker / popup (non-empty)
|
|
7
|
+
* @property {string} text description body (non-empty)
|
|
8
|
+
* @property {{ top: number, left: number }} [position] if given, becomes a free placement not tied to an element
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The helpConfig itself. For element-bound entries the key is the `data-help-id` value;
|
|
12
|
+
* for free placement it is any identifier.
|
|
13
|
+
* @typedef {Object<string, HelpEntry>} HelpConfig
|
|
14
|
+
*/
|
|
15
|
+
export function isPlainObject(value: any): boolean;
|
|
1
16
|
/**
|
|
2
17
|
* Validate the shape of helpConfig. Throws an Error on any problem (fail fast).
|
|
3
18
|
*/
|
package/dist/types/floating.d.ts
CHANGED
|
@@ -23,6 +23,18 @@ 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;
|
|
26
38
|
/**
|
|
27
39
|
* Overlap the marker onto a corner of the target (element or virtual element), stick it there, and keep it following.
|
|
28
40
|
* @param {Element|object} reference
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolation for user-supplied callbacks (render / onOpen / onClose / onEnable / onDisable).
|
|
3
|
+
*
|
|
4
|
+
* These run inside the library's own control flow (event handlers, teardown), so a throw from a
|
|
5
|
+
* caller's mistake must not derail us: it could leave a popup half-open or abort a teardown midway,
|
|
6
|
+
* stranding markers, observers and injected styles. We swallow the error and log it instead, so the
|
|
7
|
+
* developer still sees their bug while the library keeps its internal state consistent.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Invoke a user callback, never letting it throw into library code.
|
|
11
|
+
* @template T
|
|
12
|
+
* @param {string} label name used in the error log (e.g. 'onClose')
|
|
13
|
+
* @param {((...args: any[]) => T)|undefined|null} fn the callback, or nullish to skip
|
|
14
|
+
* @param {...any} args arguments forwarded to fn
|
|
15
|
+
* @returns {T|undefined} fn's return value, or undefined when absent or it threw
|
|
16
|
+
*/
|
|
17
|
+
export function safeInvoke<T>(label: string, fn: ((...args: any[]) => T) | undefined | null, ...args: any[]): T | undefined;
|
package/dist/types/toggle.d.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @param {object}
|
|
3
|
-
* @param {object}
|
|
4
|
-
* @param {string|HTMLElement} [
|
|
5
|
-
* @param {() => void} [
|
|
6
|
-
* @param {() => void} [
|
|
7
|
-
* @param {(record: import('./matcher.js').HelpRecord) => void} [
|
|
8
|
-
* @param {() => void} [
|
|
9
|
-
* @param {boolean} [
|
|
10
|
-
* @param {string} [
|
|
11
|
-
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [
|
|
2
|
+
* @param {object} options
|
|
3
|
+
* @param {object} options.config helpConfig
|
|
4
|
+
* @param {string|HTMLElement} [options.toggle] DOM element that switches ON/OFF (if omitted, programmatic control only)
|
|
5
|
+
* @param {() => void} [options.onEnable] called right after the mode is turned ON
|
|
6
|
+
* @param {() => void} [options.onDisable] called right after the mode is turned OFF
|
|
7
|
+
* @param {(record: import('./matcher.js').HelpRecord) => void} [options.onOpen] called when a popup is opened
|
|
8
|
+
* @param {() => void} [options.onClose] called when a popup is closed
|
|
9
|
+
* @param {boolean} [options.silent] suppress the warning log for unregistered keys
|
|
10
|
+
* @param {string} [options.attribute] attribute name marking targets (default 'data-help-id')
|
|
11
|
+
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render] render the popup body with your own Node
|
|
12
12
|
* (the return value is inserted as-is without sanitization, so untrusted data must be neutralized by the caller)
|
|
13
|
-
* @param {string} [
|
|
14
|
-
* @param {import('@floating-ui/dom').Placement} [
|
|
15
|
-
* @param {import('@floating-ui/dom').Placement} [
|
|
16
|
-
* @param {string} [
|
|
13
|
+
* @param {string} [options.markerLabel] character shown on the marker (default '?')
|
|
14
|
+
* @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap the marker onto (default 'top-end')
|
|
15
|
+
* @param {import('@floating-ui/dom').Placement} [options.popupPlacement] initial popup placement (default 'bottom-start')
|
|
16
|
+
* @param {string} [options.nonce] nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
|
|
17
17
|
*/
|
|
18
|
-
export function createToggleController(
|
|
18
|
+
export function createToggleController(options: {
|
|
19
19
|
config: object;
|
|
20
20
|
toggle?: string | HTMLElement;
|
|
21
21
|
onEnable?: () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "help-layer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"engines": {
|
|
@@ -32,11 +32,14 @@
|
|
|
32
32
|
"lint:fix": "eslint src tests --fix",
|
|
33
33
|
"typecheck": "tsc -p jsconfig.json",
|
|
34
34
|
"check": "npm run lint && npm run typecheck && npm test",
|
|
35
|
+
"build:site": "node demo/build-site.js",
|
|
36
|
+
"build:demos": "node demo/build-framework-demos.js",
|
|
35
37
|
"build:bundle": "node scripts/build.js",
|
|
36
38
|
"build:types": "tsc -p jsconfig.json --declaration --emitDeclarationOnly --noEmit false --rootDir src --outDir dist/types",
|
|
37
39
|
"build": "npm run build:bundle && npm run build:types",
|
|
40
|
+
"record:gif": "npm run build:demos && node scripts/record-demo.js",
|
|
38
41
|
"prepublishOnly": "npm run build",
|
|
39
|
-
"demo": "node scripts/serve.js"
|
|
42
|
+
"demo": "npm run build:demos && node scripts/serve.js"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|
|
42
45
|
"@playwright/test": "^1.61.0",
|
|
@@ -45,8 +48,10 @@
|
|
|
45
48
|
"eslint-plugin-import": "^2.32.0",
|
|
46
49
|
"eslint-plugin-n": "^17.24.0",
|
|
47
50
|
"eslint-plugin-promise": "^7.2.1",
|
|
51
|
+
"gifenc": "^1.0.3",
|
|
48
52
|
"jest": "^29.7.0",
|
|
49
53
|
"jest-environment-jsdom": "^29.7.0",
|
|
54
|
+
"pngjs": "^7.0.0",
|
|
50
55
|
"react": "^19.2.7",
|
|
51
56
|
"react-dom": "^19.2.7",
|
|
52
57
|
"typescript": "^6.0.3",
|
|
@@ -64,7 +69,7 @@
|
|
|
64
69
|
"shadow-dom",
|
|
65
70
|
"framework-agnostic"
|
|
66
71
|
],
|
|
67
|
-
"author": "Y1-Effy
|
|
72
|
+
"author": "Y1-Effy",
|
|
68
73
|
"license": "ISC",
|
|
69
74
|
"description": "Framework-agnostic, drop-in help mode for existing web apps — toggleable help markers with description popups, Shadow DOM piercing, and full cleanup.",
|
|
70
75
|
"repository": {
|
package/src/config.js
CHANGED
|
@@ -15,15 +15,17 @@
|
|
|
15
15
|
* @typedef {Object<string, HelpEntry>} HelpConfig
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
function isPlainObject(value) {
|
|
18
|
+
export function isPlainObject(value) {
|
|
19
19
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function isValidPosition(position) {
|
|
23
|
+
// Number.isFinite rejects NaN / Infinity / non-numbers, so a computed coordinate that became NaN
|
|
24
|
+
// fails validation here instead of silently pinning the marker to 0,0 at render time.
|
|
23
25
|
return (
|
|
24
26
|
isPlainObject(position) &&
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
Number.isFinite(position.top) &&
|
|
28
|
+
Number.isFinite(position.left)
|
|
27
29
|
);
|
|
28
30
|
}
|
|
29
31
|
|
|
@@ -46,7 +48,7 @@ export function validateConfig(config) {
|
|
|
46
48
|
throw new Error(`helpConfig["${key}"].text must be a non-empty string`);
|
|
47
49
|
}
|
|
48
50
|
if (entry.position !== undefined && !isValidPosition(entry.position)) {
|
|
49
|
-
throw new Error(`helpConfig["${key}"].position must be { top: number, left: number }`);
|
|
51
|
+
throw new Error(`helpConfig["${key}"].position must be { top: finite number, left: finite number }`);
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
}
|
package/src/floating.js
CHANGED
|
@@ -40,6 +40,37 @@ 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
|
+
|
|
43
74
|
// Half of the default marker size (22px). The amount used to overlap the marker onto the
|
|
44
75
|
// target's corner with an "inset". (If the marker-size CSS variable is changed, the resulting
|
|
45
76
|
// drift is left as existing behavior = not compensated for here.)
|
|
@@ -66,16 +97,26 @@ function markerOffset(placement) {
|
|
|
66
97
|
* @returns {() => void} cleanup
|
|
67
98
|
*/
|
|
68
99
|
export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end') {
|
|
100
|
+
// Match the floating element's strategy to the reference: a fixed reference needs a fixed marker, or
|
|
101
|
+
// it jitters while scrolling (see isFixedReference). Inline !important beats the stylesheet's
|
|
102
|
+
// `position: absolute !important`.
|
|
103
|
+
const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
|
|
104
|
+
if (strategy === 'fixed') {
|
|
105
|
+
markerEl.style.setProperty('position', 'fixed', 'important');
|
|
106
|
+
}
|
|
69
107
|
const update = () => {
|
|
70
108
|
computePosition(reference, markerEl, {
|
|
71
109
|
placement,
|
|
110
|
+
strategy,
|
|
72
111
|
middleware: [offset(markerOffset(placement))],
|
|
73
112
|
}).then(({ x, y }) => {
|
|
74
113
|
place(markerEl, x, y);
|
|
75
114
|
if (onPlaced) {
|
|
76
115
|
onPlaced();
|
|
77
116
|
}
|
|
78
|
-
|
|
117
|
+
// Swallow silently: this runs every animation frame, so logging would flood the console, and a
|
|
118
|
+
// stray rejection must not surface in the host app's unhandledrejection handler (e.g. Sentry).
|
|
119
|
+
}).catch(() => {});
|
|
79
120
|
};
|
|
80
121
|
// animationFrame: true syncs repositioning to the rAF loop. With the default (scroll/resize
|
|
81
122
|
// events only), computePosition resolves asynchronously, so left/top is written the frame after
|
|
@@ -93,13 +134,20 @@ export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end
|
|
|
93
134
|
* calling update repositions immediately (used for reference-side transform moves that autoUpdate doesn't pick up, etc.).
|
|
94
135
|
*/
|
|
95
136
|
export function anchorPopup(reference, popupEl, placement = 'bottom-start') {
|
|
137
|
+
// The reference is the clicked marker. If it's fixed (anchored to a fixed target), the popup must be
|
|
138
|
+
// fixed too or it jitters on scroll. Set position every open so reopening on a normal marker restores
|
|
139
|
+
// absolute. Inline !important beats the stylesheet's `position: absolute !important`.
|
|
140
|
+
const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
|
|
141
|
+
popupEl.style.setProperty('position', strategy, 'important');
|
|
96
142
|
const update = () => {
|
|
97
143
|
computePosition(reference, popupEl, {
|
|
98
144
|
placement,
|
|
145
|
+
strategy,
|
|
99
146
|
middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
|
|
100
147
|
}).then(({ x, y }) => {
|
|
101
148
|
place(popupEl, x, y);
|
|
102
|
-
|
|
149
|
+
// Same rationale as anchorMarker: swallow per-frame rejections so they don't reach the host.
|
|
150
|
+
}).catch(() => {});
|
|
103
151
|
};
|
|
104
152
|
// animationFrame: true for the same smooth-tracking reason as anchorMarker. The reference here is
|
|
105
153
|
// the marker element, which itself moves per frame, so the popup must track per frame to stay glued.
|
package/src/observer.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* elements and mounts/unmounts markers dynamically.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { safeInvoke } from './safe.js';
|
|
13
|
+
|
|
12
14
|
const ELEMENT_NODE = 1;
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -115,11 +117,13 @@ export function createMutationWatcher({ root = document, selector, onAdded, onRe
|
|
|
115
117
|
// For added nodes, get both "matching elements" and "shadowRoots to start observing" in one traversal per subtree.
|
|
116
118
|
record.addedNodes.forEach((node) => {
|
|
117
119
|
const { matches, shadowRoots } = scanSubtree(node, selector);
|
|
118
|
-
|
|
120
|
+
// Isolate each callback: a throw on one element must not abort the rest of the batch nor
|
|
121
|
+
// kill ongoing observation (which would silently stop all later SPA tracking).
|
|
122
|
+
matches.forEach((el) => safeInvoke('observer onAdded', onAdded, el));
|
|
119
123
|
shadowRoots.forEach(observe);
|
|
120
124
|
});
|
|
121
125
|
record.removedNodes.forEach((node) => {
|
|
122
|
-
scanSubtree(node, selector).matches.forEach((el) => onRemoved
|
|
126
|
+
scanSubtree(node, selector).matches.forEach((el) => safeInvoke('observer onRemoved', onRemoved, el));
|
|
123
127
|
});
|
|
124
128
|
}
|
|
125
129
|
};
|
package/src/popup.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { createPopup } from './dom-builder.js';
|
|
11
11
|
import { anchorPopup } from './floating.js';
|
|
12
|
+
import { safeInvoke } from './safe.js';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* @param {object} state teardown registry
|
|
@@ -22,6 +23,9 @@ import { anchorPopup } from './floating.js';
|
|
|
22
23
|
*/
|
|
23
24
|
export function createPopupController(state, { onClose, render, popupPlacement = 'bottom-start' } = {}) {
|
|
24
25
|
const { root, titleEl, textEl, closeEl } = createPopup();
|
|
26
|
+
// Drive the open/close state with an inline !important display so it beats both this library's own
|
|
27
|
+
// stylesheet and any host rule (e.g. div { display:none !important }). Start hidden.
|
|
28
|
+
root.style.setProperty('display', 'none', 'important');
|
|
25
29
|
document.body.appendChild(root);
|
|
26
30
|
|
|
27
31
|
// The close (×) button. root is removed on teardown, so explicitly detaching the listener isn't needed.
|
|
@@ -45,14 +49,15 @@ export function createPopupController(state, { onClose, render, popupPlacement =
|
|
|
45
49
|
function open(record, referenceEl) {
|
|
46
50
|
titleEl.textContent = record.title;
|
|
47
51
|
// If render exists, replace the body with a custom Node; otherwise fall back to safe text rendering.
|
|
48
|
-
|
|
52
|
+
// A throwing render yields undefined here, so we degrade to the safe textContent path below.
|
|
53
|
+
const custom = safeInvoke('render', render, record);
|
|
49
54
|
textEl.textContent = '';
|
|
50
55
|
if (custom) {
|
|
51
56
|
textEl.appendChild(custom);
|
|
52
57
|
} else {
|
|
53
58
|
textEl.textContent = record.text;
|
|
54
59
|
}
|
|
55
|
-
root.style.display
|
|
60
|
+
root.style.setProperty('display', 'block', 'important');
|
|
56
61
|
openId = record.id;
|
|
57
62
|
triggerEl = referenceEl;
|
|
58
63
|
|
|
@@ -79,9 +84,9 @@ export function createPopupController(state, { onClose, render, popupPlacement =
|
|
|
79
84
|
stopAnchor();
|
|
80
85
|
openId = null;
|
|
81
86
|
triggerEl = null;
|
|
82
|
-
root.style.display
|
|
83
|
-
if (wasOpen
|
|
84
|
-
onClose
|
|
87
|
+
root.style.setProperty('display', 'none', 'important');
|
|
88
|
+
if (wasOpen) {
|
|
89
|
+
safeInvoke('onClose', onClose);
|
|
85
90
|
}
|
|
86
91
|
}
|
|
87
92
|
|
package/src/safe.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolation for user-supplied callbacks (render / onOpen / onClose / onEnable / onDisable).
|
|
3
|
+
*
|
|
4
|
+
* These run inside the library's own control flow (event handlers, teardown), so a throw from a
|
|
5
|
+
* caller's mistake must not derail us: it could leave a popup half-open or abort a teardown midway,
|
|
6
|
+
* stranding markers, observers and injected styles. We swallow the error and log it instead, so the
|
|
7
|
+
* developer still sees their bug while the library keeps its internal state consistent.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Invoke a user callback, never letting it throw into library code.
|
|
12
|
+
* @template T
|
|
13
|
+
* @param {string} label name used in the error log (e.g. 'onClose')
|
|
14
|
+
* @param {((...args: any[]) => T)|undefined|null} fn the callback, or nullish to skip
|
|
15
|
+
* @param {...any} args arguments forwarded to fn
|
|
16
|
+
* @returns {T|undefined} fn's return value, or undefined when absent or it threw
|
|
17
|
+
*/
|
|
18
|
+
export function safeInvoke(label, fn, ...args) {
|
|
19
|
+
if (!fn) {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
return fn(...args);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// Always logged regardless of `silent` (that flag only gates unregistered-key warnings).
|
|
26
|
+
console.error(`[help-layer] ${label} threw:`, err);
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/state.js
CHANGED
|
@@ -14,7 +14,13 @@ export function createState() {
|
|
|
14
14
|
teardownAll() {
|
|
15
15
|
while (cleanupFns.length > 0) {
|
|
16
16
|
const cleanup = cleanupFns.pop();
|
|
17
|
-
cleanup()
|
|
17
|
+
// A throwing cleanup (e.g. a user onClose run during teardown) must not abort the rest of
|
|
18
|
+
// the unwind, otherwise later-registered subsystems (markers, observer, styles) would leak.
|
|
19
|
+
try {
|
|
20
|
+
cleanup();
|
|
21
|
+
} catch (err) {
|
|
22
|
+
console.error('[help-layer] teardown step threw:', err);
|
|
23
|
+
}
|
|
18
24
|
}
|
|
19
25
|
},
|
|
20
26
|
};
|
package/src/style.js
CHANGED
|
@@ -25,15 +25,18 @@ const STYLE_ATTR = 'data-help-layer-style';
|
|
|
25
25
|
// --help-layer-overlay-cursor cursor over the blocked area (default default; e.g. not-allowed / help)
|
|
26
26
|
const CSS = `
|
|
27
27
|
.help-layer-blocking-layer {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/* Structural properties !important so a host can't accidentally un-fix or restack the layer and
|
|
29
|
+
defeat the blocking guarantee. */
|
|
30
|
+
position: fixed !important;
|
|
31
|
+
inset: 0 !important;
|
|
32
|
+
pointer-events: auto !important;
|
|
30
33
|
/* Default transparent (unchanged). Set --help-layer-overlay-bg to tint it into a scrim that signals
|
|
31
34
|
"the host app is inactive". The clip-path hole isn't painted, so the toggle stays untinted. */
|
|
32
35
|
background: var(--help-layer-overlay-bg, transparent);
|
|
33
36
|
/* Cursor over the blocked area only (the toggle shows through the hole and keeps its own cursor).
|
|
34
37
|
e.g. not-allowed / help makes "this won't respond" obvious without needing a tint. */
|
|
35
38
|
cursor: var(--help-layer-overlay-cursor, default);
|
|
36
|
-
z-index: ${Z_BLOCKING_LAYER};
|
|
39
|
+
z-index: ${Z_BLOCKING_LAYER} !important;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
.help-layer-marker {
|
|
@@ -43,11 +46,20 @@ const CSS = `
|
|
|
43
46
|
margin: 0;
|
|
44
47
|
padding: 0;
|
|
45
48
|
border: none;
|
|
46
|
-
|
|
49
|
+
/* Structural properties are !important so a host's broad rules (e.g. button { display:none }) can't
|
|
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.
|
|
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. */
|
|
54
|
+
position: absolute !important;
|
|
55
|
+
display: block !important;
|
|
56
|
+
visibility: visible !important;
|
|
57
|
+
opacity: 1 !important;
|
|
58
|
+
pointer-events: auto !important;
|
|
47
59
|
top: 0;
|
|
48
60
|
left: 0;
|
|
49
|
-
width: var(--help-layer-marker-size, 22px);
|
|
50
|
-
height: var(--help-layer-marker-size, 22px);
|
|
61
|
+
width: var(--help-layer-marker-size, 22px) !important;
|
|
62
|
+
height: var(--help-layer-marker-size, 22px) !important;
|
|
51
63
|
border-radius: 50%;
|
|
52
64
|
background: var(--help-layer-marker-bg, #2563eb);
|
|
53
65
|
color: var(--help-layer-marker-color, #fff);
|
|
@@ -59,7 +71,7 @@ const CSS = `
|
|
|
59
71
|
cursor: pointer;
|
|
60
72
|
user-select: none;
|
|
61
73
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
|
62
|
-
z-index: ${Z_TOP};
|
|
74
|
+
z-index: ${Z_TOP} !important;
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
.help-layer-marker:focus-visible {
|
|
@@ -68,7 +80,13 @@ const CSS = `
|
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
.help-layer-popup {
|
|
71
|
-
|
|
83
|
+
/* Structural !important guards against host resets; top/left stay inline (place()), and display is
|
|
84
|
+
deliberately NOT !important here — popup.js toggles it via an inline !important declaration so the
|
|
85
|
+
open/close state itself can also beat a host rule without this stylesheet fighting the toggle. */
|
|
86
|
+
position: absolute !important;
|
|
87
|
+
visibility: visible !important;
|
|
88
|
+
opacity: 1 !important;
|
|
89
|
+
pointer-events: auto !important;
|
|
72
90
|
top: 0;
|
|
73
91
|
left: 0;
|
|
74
92
|
display: none;
|
|
@@ -81,7 +99,7 @@ const CSS = `
|
|
|
81
99
|
font-family: sans-serif;
|
|
82
100
|
font-size: 13px;
|
|
83
101
|
line-height: 1.5;
|
|
84
|
-
z-index: ${Z_POPUP};
|
|
102
|
+
z-index: ${Z_POPUP} !important;
|
|
85
103
|
}
|
|
86
104
|
|
|
87
105
|
.help-layer-popup:focus {
|
|
@@ -112,7 +130,10 @@ const CSS = `
|
|
|
112
130
|
/* reset of the button element */
|
|
113
131
|
appearance: none;
|
|
114
132
|
-webkit-appearance: none;
|
|
115
|
-
|
|
133
|
+
/* Keep the close affordance visible/placed even under host button { ... } rules. */
|
|
134
|
+
display: block !important;
|
|
135
|
+
position: absolute !important;
|
|
136
|
+
pointer-events: auto !important;
|
|
116
137
|
top: 6px;
|
|
117
138
|
right: 6px;
|
|
118
139
|
width: 22px;
|
package/src/toggle.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* and aggregates their teardown into the cleanup registry (state).
|
|
5
5
|
*/
|
|
6
6
|
import { activateBlockingLayer } from './blocking-layer.js';
|
|
7
|
-
import { normalizeConfig, validateConfig } from './config.js';
|
|
7
|
+
import { isPlainObject, normalizeConfig, validateConfig } from './config.js';
|
|
8
8
|
import { createMarkerManager } from './markers.js';
|
|
9
9
|
import {
|
|
10
10
|
collectElementRecords,
|
|
@@ -15,49 +15,65 @@ import {
|
|
|
15
15
|
} from './matcher.js';
|
|
16
16
|
import { createMutationWatcher } from './observer.js';
|
|
17
17
|
import { createPopupController } from './popup.js';
|
|
18
|
+
import { safeInvoke } from './safe.js';
|
|
18
19
|
import { createState } from './state.js';
|
|
19
20
|
import { injectStyles, removeStyles } from './style.js';
|
|
20
21
|
|
|
21
22
|
function resolveToggleElement(toggle) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
if (typeof toggle === 'string') {
|
|
24
|
+
const el = document.querySelector(toggle);
|
|
25
|
+
if (!el) {
|
|
26
|
+
throw new Error(`help-layer: toggle element not found for selector "${toggle}"`);
|
|
27
|
+
}
|
|
28
|
+
return /** @type {HTMLElement} */ (el);
|
|
29
|
+
}
|
|
30
|
+
// Reject truthy garbage (a number, a plain object, ...) early; otherwise it would be accepted as a
|
|
31
|
+
// toggle and only blow up cryptically later at toggleEl.addEventListener.
|
|
32
|
+
if (toggle instanceof HTMLElement) {
|
|
33
|
+
return toggle;
|
|
25
34
|
}
|
|
26
|
-
|
|
35
|
+
throw new Error('help-layer: toggle must be a CSS selector string or a DOM element');
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
/**
|
|
30
|
-
* @param {object}
|
|
31
|
-
* @param {object}
|
|
32
|
-
* @param {string|HTMLElement} [
|
|
33
|
-
* @param {() => void} [
|
|
34
|
-
* @param {() => void} [
|
|
35
|
-
* @param {(record: import('./matcher.js').HelpRecord) => void} [
|
|
36
|
-
* @param {() => void} [
|
|
37
|
-
* @param {boolean} [
|
|
38
|
-
* @param {string} [
|
|
39
|
-
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [
|
|
39
|
+
* @param {object} options
|
|
40
|
+
* @param {object} options.config helpConfig
|
|
41
|
+
* @param {string|HTMLElement} [options.toggle] DOM element that switches ON/OFF (if omitted, programmatic control only)
|
|
42
|
+
* @param {() => void} [options.onEnable] called right after the mode is turned ON
|
|
43
|
+
* @param {() => void} [options.onDisable] called right after the mode is turned OFF
|
|
44
|
+
* @param {(record: import('./matcher.js').HelpRecord) => void} [options.onOpen] called when a popup is opened
|
|
45
|
+
* @param {() => void} [options.onClose] called when a popup is closed
|
|
46
|
+
* @param {boolean} [options.silent] suppress the warning log for unregistered keys
|
|
47
|
+
* @param {string} [options.attribute] attribute name marking targets (default 'data-help-id')
|
|
48
|
+
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render] render the popup body with your own Node
|
|
40
49
|
* (the return value is inserted as-is without sanitization, so untrusted data must be neutralized by the caller)
|
|
41
|
-
* @param {string} [
|
|
42
|
-
* @param {import('@floating-ui/dom').Placement} [
|
|
43
|
-
* @param {import('@floating-ui/dom').Placement} [
|
|
44
|
-
* @param {string} [
|
|
50
|
+
* @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')
|
|
53
|
+
* @param {string} [options.nonce] nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
|
|
45
54
|
*/
|
|
46
|
-
export function createToggleController({
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
export function createToggleController(options) {
|
|
56
|
+
// Validate before destructuring so initHelpLayer() / initHelpLayer(null) get a clear message
|
|
57
|
+
// instead of a cryptic "Cannot destructure property 'config' of undefined".
|
|
58
|
+
if (!isPlainObject(options)) {
|
|
59
|
+
throw new Error('help-layer: initHelpLayer requires an options object');
|
|
60
|
+
}
|
|
61
|
+
const {
|
|
62
|
+
config,
|
|
63
|
+
toggle,
|
|
64
|
+
onEnable,
|
|
65
|
+
onDisable,
|
|
66
|
+
onOpen,
|
|
67
|
+
onClose,
|
|
68
|
+
silent = false,
|
|
69
|
+
attribute = 'data-help-id',
|
|
70
|
+
render,
|
|
71
|
+
markerLabel = '?',
|
|
72
|
+
markerPlacement = 'top-end',
|
|
73
|
+
popupPlacement = 'bottom-start',
|
|
74
|
+
nonce,
|
|
75
|
+
} = options;
|
|
76
|
+
|
|
61
77
|
let activeConfig = config;
|
|
62
78
|
validateConfig(activeConfig);
|
|
63
79
|
// The toggle element is optional. If omitted, it's driven solely by programmatic control like enable()/disable().
|
|
@@ -100,9 +116,7 @@ export function createToggleController({
|
|
|
100
116
|
return;
|
|
101
117
|
}
|
|
102
118
|
popup.open(record, markerEl);
|
|
103
|
-
|
|
104
|
-
onOpen(record);
|
|
105
|
-
}
|
|
119
|
+
safeInvoke('onOpen', onOpen, record);
|
|
106
120
|
},
|
|
107
121
|
// When overlap avoidance moves a marker, make the open popup follow.
|
|
108
122
|
onOverlapResolved: () => popup.reposition(),
|
|
@@ -165,9 +179,7 @@ export function createToggleController({
|
|
|
165
179
|
return;
|
|
166
180
|
}
|
|
167
181
|
turnOn();
|
|
168
|
-
|
|
169
|
-
onEnable();
|
|
170
|
-
}
|
|
182
|
+
safeInvoke('onEnable', onEnable);
|
|
171
183
|
}
|
|
172
184
|
|
|
173
185
|
function disable() {
|
|
@@ -175,9 +187,7 @@ export function createToggleController({
|
|
|
175
187
|
return;
|
|
176
188
|
}
|
|
177
189
|
turnOff();
|
|
178
|
-
|
|
179
|
-
onDisable();
|
|
180
|
-
}
|
|
190
|
+
safeInvoke('onDisable', onDisable);
|
|
181
191
|
}
|
|
182
192
|
|
|
183
193
|
function toggleMode() {
|
|
@@ -204,9 +214,7 @@ export function createToggleController({
|
|
|
204
214
|
return;
|
|
205
215
|
}
|
|
206
216
|
popup.open(entry.record, entry.el);
|
|
207
|
-
|
|
208
|
-
onOpen(entry.record);
|
|
209
|
-
}
|
|
217
|
+
safeInvoke('onOpen', onOpen, entry.record);
|
|
210
218
|
}
|
|
211
219
|
|
|
212
220
|
// Close the open description (does not turn the mode itself OFF).
|