help-layer 1.0.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/src/toggle.js ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Orchestration of the help mode's ON/OFF.
3
+ * Starts each subsystem (style injection, marker manager, popup, blocking layer, DOM observation)
4
+ * and aggregates their teardown into the cleanup registry (state).
5
+ */
6
+ import { activateBlockingLayer } from './blocking-layer.js';
7
+ import { normalizeConfig, validateConfig } from './config.js';
8
+ import { createMarkerManager } from './markers.js';
9
+ import {
10
+ collectElementRecords,
11
+ elementConfigMap,
12
+ freeRecords,
13
+ recordForElement,
14
+ targetSelector,
15
+ } from './matcher.js';
16
+ import { createMutationWatcher } from './observer.js';
17
+ import { createPopupController } from './popup.js';
18
+ import { createState } from './state.js';
19
+ import { injectStyles, removeStyles } from './style.js';
20
+
21
+ 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}"`);
25
+ }
26
+ return toggleEl;
27
+ }
28
+
29
+ /**
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
40
+ * (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-…')
45
+ */
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
+ }) {
61
+ let activeConfig = config;
62
+ validateConfig(activeConfig);
63
+ // The toggle element is optional. If omitted, it's driven solely by programmatic control like enable()/disable().
64
+ const toggleEl = toggle != null ? resolveToggleElement(toggle) : null;
65
+
66
+ let state = null;
67
+ // References to the current subsystems that exist only while ON. Hoisted because open(key)/close() touch them too.
68
+ let popup = null;
69
+ let markers = null;
70
+
71
+ // Only builds the side effects (onEnable/onDisable aren't called here; they fire on the enable/disable side).
72
+ function turnOn() {
73
+ if (state) {
74
+ return;
75
+ }
76
+ state = createState();
77
+
78
+ // On OFF, return focus to the toggle last (at the LIFO tail) (only when there is a toggle).
79
+ if (toggleEl) {
80
+ state.track(() => {
81
+ if (toggleEl.isConnected && typeof toggleEl.focus === 'function') {
82
+ toggleEl.focus({ preventScroll: true });
83
+ }
84
+ });
85
+ }
86
+
87
+ const styleEl = injectStyles(nonce);
88
+ state.track(() => removeStyles(styleEl));
89
+
90
+ const items = normalizeConfig(activeConfig);
91
+ const configMap = elementConfigMap(items);
92
+
93
+ popup = createPopupController(state, { onClose, render, popupPlacement });
94
+ markers = createMarkerManager(state, {
95
+ markerLabel,
96
+ markerPlacement,
97
+ onMarkerClick: (record, markerEl) => {
98
+ if (popup.isOpen(record.id)) {
99
+ popup.close();
100
+ return;
101
+ }
102
+ popup.open(record, markerEl);
103
+ if (onOpen) {
104
+ onOpen(record);
105
+ }
106
+ },
107
+ // When overlap avoidance moves a marker, make the open popup follow.
108
+ onOverlapResolved: () => popup.reposition(),
109
+ });
110
+
111
+ // Initial mount (free placements + elements currently in the DOM, including Shadow DOM)
112
+ markers.mountAll(freeRecords(items));
113
+ markers.mountAll(collectElementRecords(items, document, { silent, attribute }));
114
+
115
+ // SPA dynamic elements: follow additions/removals while ON
116
+ const watcher = createMutationWatcher({
117
+ selector: targetSelector(attribute),
118
+ onAdded: (el) => {
119
+ const record = recordForElement(el, configMap, attribute);
120
+ if (record && !markers.has(record.id)) {
121
+ markers.mount(record);
122
+ }
123
+ },
124
+ onRemoved: (el) => {
125
+ // Both the target and its marker disappear, so return focus to the toggle (or the default if absent).
126
+ if (popup.isOpen(el)) {
127
+ popup.close(toggleEl ?? undefined);
128
+ }
129
+ markers.unmount(el);
130
+ },
131
+ });
132
+ state.track(() => watcher.disconnect());
133
+
134
+ const isLibraryElement = (target) =>
135
+ !!target &&
136
+ ((toggleEl ? toggleEl.contains(target) : false) ||
137
+ popup.root.contains(target) ||
138
+ (typeof target.closest === 'function' && !!target.closest('.help-layer-marker')));
139
+
140
+ activateBlockingLayer(state, {
141
+ toggleEl,
142
+ onBackgroundClick: () => popup.close(),
143
+ isLibraryElement,
144
+ onEscape: () => {
145
+ if (popup.getOpenId() !== null) {
146
+ popup.close();
147
+ } else {
148
+ disable();
149
+ }
150
+ },
151
+ });
152
+ }
153
+
154
+ function turnOff() {
155
+ if (state) {
156
+ state.teardownAll();
157
+ state = null;
158
+ popup = null;
159
+ markers = null;
160
+ }
161
+ }
162
+
163
+ function enable() {
164
+ if (state) {
165
+ return;
166
+ }
167
+ turnOn();
168
+ if (onEnable) {
169
+ onEnable();
170
+ }
171
+ }
172
+
173
+ function disable() {
174
+ if (!state) {
175
+ return;
176
+ }
177
+ turnOff();
178
+ if (onDisable) {
179
+ onDisable();
180
+ }
181
+ }
182
+
183
+ function toggleMode() {
184
+ if (state) {
185
+ disable();
186
+ } else {
187
+ enable();
188
+ }
189
+ }
190
+
191
+ // Open the description for a given key programmatically. When OFF, first enable() to create the markers.
192
+ function openByKey(key) {
193
+ if (!state) {
194
+ enable();
195
+ }
196
+ if (!markers || !popup) {
197
+ return;
198
+ }
199
+ const entry = markers.findByKey(key);
200
+ if (!entry) {
201
+ if (!silent) {
202
+ console.warn(`[help-layer] open(): no help marker for key "${key}"`);
203
+ }
204
+ return;
205
+ }
206
+ popup.open(entry.record, entry.el);
207
+ if (onOpen) {
208
+ onOpen(entry.record);
209
+ }
210
+ }
211
+
212
+ // Close the open description (does not turn the mode itself OFF).
213
+ function closePopup() {
214
+ if (popup) {
215
+ popup.close();
216
+ }
217
+ }
218
+
219
+ // Replace the helpConfig. If ON, rebuild silently (onEnable/onDisable are not fired).
220
+ function update(newConfig) {
221
+ validateConfig(newConfig);
222
+ activeConfig = newConfig;
223
+ if (state) {
224
+ turnOff();
225
+ turnOn();
226
+ }
227
+ }
228
+
229
+ if (toggleEl) {
230
+ toggleEl.addEventListener('click', toggleMode);
231
+ }
232
+
233
+ return {
234
+ enable,
235
+ disable,
236
+ toggle: toggleMode,
237
+ isActive() {
238
+ return state !== null;
239
+ },
240
+ open: openByKey,
241
+ close: closePopup,
242
+ update,
243
+ destroy() {
244
+ disable();
245
+ if (toggleEl) {
246
+ toggleEl.removeEventListener('click', toggleMode);
247
+ }
248
+ },
249
+ };
250
+ }