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.
@@ -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
  */
@@ -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;
@@ -1,21 +1,21 @@
1
1
  /**
2
- * @param {object} params
3
- * @param {object} params.config helpConfig
4
- * @param {string|HTMLElement} [params.toggle] DOM element that switches ON/OFF (if omitted, programmatic control only)
5
- * @param {() => void} [params.onEnable] called right after the mode is turned ON
6
- * @param {() => void} [params.onDisable] called right after the mode is turned OFF
7
- * @param {(record: import('./matcher.js').HelpRecord) => void} [params.onOpen] called when a popup is opened
8
- * @param {() => void} [params.onClose] called when a popup is closed
9
- * @param {boolean} [params.silent] suppress the warning log for unregistered keys
10
- * @param {string} [params.attribute] attribute name marking targets (default 'data-help-id')
11
- * @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [params.render] render the popup body with your own Node
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} [params.markerLabel] character shown on the marker (default '?')
14
- * @param {import('@floating-ui/dom').Placement} [params.markerPlacement] corner to overlap the marker onto (default 'top-end')
15
- * @param {import('@floating-ui/dom').Placement} [params.popupPlacement] initial popup placement (default 'bottom-start')
16
- * @param {string} [params.nonce] nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
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({ config, toggle, onEnable, onDisable, onOpen, onClose, silent, attribute, render, markerLabel, markerPlacement, popupPlacement, nonce, }: {
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.0",
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 <ssoruto@yahoo.co.jp>",
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
- typeof position.top === 'number' &&
26
- typeof position.left === 'number'
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
- matches.forEach((el) => onAdded(el));
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(el));
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
- const custom = render ? render(record) : null;
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 = 'block';
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 = 'none';
83
- if (wasOpen && onClose) {
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
- position: fixed;
29
- inset: 0;
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
- position: absolute;
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
- position: absolute;
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
- position: absolute;
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
- const toggleEl = typeof toggle === 'string' ? document.querySelector(toggle) : toggle;
23
- if (!toggleEl) {
24
- throw new Error(`help-layer: toggle element not found for selector "${toggle}"`);
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
- return toggleEl;
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} params
31
- * @param {object} params.config helpConfig
32
- * @param {string|HTMLElement} [params.toggle] DOM element that switches ON/OFF (if omitted, programmatic control only)
33
- * @param {() => void} [params.onEnable] called right after the mode is turned ON
34
- * @param {() => void} [params.onDisable] called right after the mode is turned OFF
35
- * @param {(record: import('./matcher.js').HelpRecord) => void} [params.onOpen] called when a popup is opened
36
- * @param {() => void} [params.onClose] called when a popup is closed
37
- * @param {boolean} [params.silent] suppress the warning log for unregistered keys
38
- * @param {string} [params.attribute] attribute name marking targets (default 'data-help-id')
39
- * @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [params.render] render the popup body with your own Node
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} [params.markerLabel] character shown on the marker (default '?')
42
- * @param {import('@floating-ui/dom').Placement} [params.markerPlacement] corner to overlap the marker onto (default 'top-end')
43
- * @param {import('@floating-ui/dom').Placement} [params.popupPlacement] initial popup placement (default 'bottom-start')
44
- * @param {string} [params.nonce] nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
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
- config,
48
- toggle,
49
- onEnable,
50
- onDisable,
51
- onOpen,
52
- onClose,
53
- silent = false,
54
- attribute = 'data-help-id',
55
- render,
56
- markerLabel = '?',
57
- markerPlacement = 'top-end',
58
- popupPlacement = 'bottom-start',
59
- nonce,
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
- if (onOpen) {
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
- if (onEnable) {
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
- if (onDisable) {
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
- if (onOpen) {
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).