help-layer 1.0.0 → 1.0.1
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/dist/help-layer.esm.js +32 -13
- package/dist/help-layer.esm.js.map +4 -4
- package/dist/help-layer.iife.js +32 -13
- package/dist/help-layer.iife.js.map +4 -4
- package/dist/types/config.d.ts +15 -0
- package/dist/types/safe.d.ts +17 -0
- package/dist/types/toggle.d.ts +15 -15
- package/package.json +2 -2
- package/src/config.js +6 -4
- package/src/floating.js +5 -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 +29 -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
|
*/
|
|
@@ -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.0.
|
|
3
|
+
"version": "1.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"engines": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"shadow-dom",
|
|
65
65
|
"framework-agnostic"
|
|
66
66
|
],
|
|
67
|
-
"author": "Y1-Effy
|
|
67
|
+
"author": "Y1-Effy",
|
|
68
68
|
"license": "ISC",
|
|
69
69
|
"description": "Framework-agnostic, drop-in help mode for existing web apps — toggleable help markers with description popups, Shadow DOM piercing, and full cleanup.",
|
|
70
70
|
"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
|
@@ -75,7 +75,9 @@ export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end
|
|
|
75
75
|
if (onPlaced) {
|
|
76
76
|
onPlaced();
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
// Swallow silently: this runs every animation frame, so logging would flood the console, and a
|
|
79
|
+
// stray rejection must not surface in the host app's unhandledrejection handler (e.g. Sentry).
|
|
80
|
+
}).catch(() => {});
|
|
79
81
|
};
|
|
80
82
|
// animationFrame: true syncs repositioning to the rAF loop. With the default (scroll/resize
|
|
81
83
|
// events only), computePosition resolves asynchronously, so left/top is written the frame after
|
|
@@ -99,7 +101,8 @@ export function anchorPopup(reference, popupEl, placement = 'bottom-start') {
|
|
|
99
101
|
middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
|
|
100
102
|
}).then(({ x, y }) => {
|
|
101
103
|
place(popupEl, x, y);
|
|
102
|
-
|
|
104
|
+
// Same rationale as anchorMarker: swallow per-frame rejections so they don't reach the host.
|
|
105
|
+
}).catch(() => {});
|
|
103
106
|
};
|
|
104
107
|
// animationFrame: true for the same smooth-tracking reason as anchorMarker. The reference here is
|
|
105
108
|
// 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,18 @@ 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
|
+
position: absolute !important;
|
|
53
|
+
display: block !important;
|
|
54
|
+
visibility: visible !important;
|
|
55
|
+
opacity: 1 !important;
|
|
56
|
+
pointer-events: auto !important;
|
|
47
57
|
top: 0;
|
|
48
58
|
left: 0;
|
|
49
|
-
width: var(--help-layer-marker-size, 22px);
|
|
50
|
-
height: var(--help-layer-marker-size, 22px);
|
|
59
|
+
width: var(--help-layer-marker-size, 22px) !important;
|
|
60
|
+
height: var(--help-layer-marker-size, 22px) !important;
|
|
51
61
|
border-radius: 50%;
|
|
52
62
|
background: var(--help-layer-marker-bg, #2563eb);
|
|
53
63
|
color: var(--help-layer-marker-color, #fff);
|
|
@@ -59,7 +69,7 @@ const CSS = `
|
|
|
59
69
|
cursor: pointer;
|
|
60
70
|
user-select: none;
|
|
61
71
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
|
|
62
|
-
z-index: ${Z_TOP};
|
|
72
|
+
z-index: ${Z_TOP} !important;
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
.help-layer-marker:focus-visible {
|
|
@@ -68,7 +78,13 @@ const CSS = `
|
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
.help-layer-popup {
|
|
71
|
-
|
|
81
|
+
/* Structural !important guards against host resets; top/left stay inline (place()), and display is
|
|
82
|
+
deliberately NOT !important here — popup.js toggles it via an inline !important declaration so the
|
|
83
|
+
open/close state itself can also beat a host rule without this stylesheet fighting the toggle. */
|
|
84
|
+
position: absolute !important;
|
|
85
|
+
visibility: visible !important;
|
|
86
|
+
opacity: 1 !important;
|
|
87
|
+
pointer-events: auto !important;
|
|
72
88
|
top: 0;
|
|
73
89
|
left: 0;
|
|
74
90
|
display: none;
|
|
@@ -81,7 +97,7 @@ const CSS = `
|
|
|
81
97
|
font-family: sans-serif;
|
|
82
98
|
font-size: 13px;
|
|
83
99
|
line-height: 1.5;
|
|
84
|
-
z-index: ${Z_POPUP};
|
|
100
|
+
z-index: ${Z_POPUP} !important;
|
|
85
101
|
}
|
|
86
102
|
|
|
87
103
|
.help-layer-popup:focus {
|
|
@@ -112,7 +128,10 @@ const CSS = `
|
|
|
112
128
|
/* reset of the button element */
|
|
113
129
|
appearance: none;
|
|
114
130
|
-webkit-appearance: none;
|
|
115
|
-
|
|
131
|
+
/* Keep the close affordance visible/placed even under host button { ... } rules. */
|
|
132
|
+
display: block !important;
|
|
133
|
+
position: absolute !important;
|
|
134
|
+
pointer-events: auto !important;
|
|
116
135
|
top: 6px;
|
|
117
136
|
right: 6px;
|
|
118
137
|
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).
|