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.
@@ -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;
@@ -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.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 <ssoruto@yahoo.co.jp>",
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
- 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
@@ -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
- 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,18 @@ 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
+ 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
- position: absolute;
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
- position: absolute;
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
- 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).