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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * DOM observation and Shadow DOM-piercing traversal.
3
+ *
4
+ * A normal querySelectorAll does not cross shadow boundaries, so queryAllDeep walks open
5
+ * shadowRoots recursively. A closed shadowRoot is unreachable from JS, so it is unsupported
6
+ * (a known limitation).
7
+ *
8
+ * For SPA support, while ON a MutationObserver watches for additions/removals of data-help-id
9
+ * elements and mounts/unmounts markers dynamically.
10
+ */
11
+
12
+ const ELEMENT_NODE = 1;
13
+
14
+ /**
15
+ * Internal worker that traverses everything under root (including inside open shadowRoots) once,
16
+ * collecting both selector-matching elements and shadowRoots at the same time. It uses a single
17
+ * `querySelectorAll('*')` pass and does both the `matches` test and shadowRoot detection within it.
18
+ * The goal is to cut what used to be two separate full scans ("collect matches" and "find shadow
19
+ * hosts") down to one (lightening the hot path that reacts to host DOM changes while ON).
20
+ * @param {ParentNode} root
21
+ * @param {string} selector
22
+ * @param {(el: Element) => void} [onMatch]
23
+ * @param {(shadow: ShadowRoot) => void} [onShadowRoot]
24
+ */
25
+ function walkDeep(root, selector, onMatch, onShadowRoot) {
26
+ if (typeof root.querySelectorAll !== 'function') {
27
+ return;
28
+ }
29
+ // querySelectorAll('*') does not cross shadow boundaries, so run it flatly once per root and,
30
+ // when a shadowRoot is found, recurse into it right there (depth-first).
31
+ root.querySelectorAll('*').forEach((el) => {
32
+ if (onMatch && el.matches(selector)) {
33
+ onMatch(el);
34
+ }
35
+ if (el.shadowRoot) {
36
+ if (onShadowRoot) {
37
+ onShadowRoot(el.shadowRoot);
38
+ }
39
+ walkDeep(el.shadowRoot, selector, onMatch, onShadowRoot);
40
+ }
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Collect every element under root (including inside open shadowRoots) matching selector.
46
+ * @param {ParentNode} root
47
+ * @param {string} selector
48
+ * @returns {Element[]}
49
+ */
50
+ export function queryAllDeep(root, selector) {
51
+ const results = [];
52
+ walkDeep(root, selector, (el) => results.push(el));
53
+ return results;
54
+ }
55
+
56
+ /**
57
+ * Collect every open shadowRoot under root (excluding root itself).
58
+ * @param {ParentNode} root
59
+ * @returns {ShadowRoot[]}
60
+ */
61
+ export function collectShadowRoots(root) {
62
+ const roots = [];
63
+ walkDeep(root, '*', null, (shadow) => roots.push(shadow));
64
+ return roots;
65
+ }
66
+
67
+ /**
68
+ * Traverse a node and its descendants (including shadow) once, returning both selector-matching
69
+ * elements and the descendants' open shadowRoots together. Used in MutationObserver added-node
70
+ * handling to fold "collect matches" and "add shadow observation" into one traversal per subtree.
71
+ * @param {Node} node
72
+ * @param {string} selector
73
+ * @returns {{ matches: Element[], shadowRoots: ShadowRoot[] }}
74
+ */
75
+ function scanSubtree(node, selector) {
76
+ /** @type {Element[]} */
77
+ const matches = [];
78
+ /** @type {ShadowRoot[]} */
79
+ const shadowRoots = [];
80
+ if (node.nodeType !== ELEMENT_NODE) {
81
+ return { matches, shadowRoots };
82
+ }
83
+ // node itself is not included in querySelectorAll('*'), so test it separately (equivalent to the former matchingWithin).
84
+ const el = /** @type {Element} */ (node);
85
+ if (typeof el.matches === 'function' && el.matches(selector)) {
86
+ matches.push(el);
87
+ }
88
+ // The added node itself may be a shadow host. walkDeep only inspects the shadowRoots of
89
+ // descendants (querySelectorAll('*') never includes the root), so handle the node's own
90
+ // shadowRoot here before descending into the light-DOM subtree.
91
+ if (el.shadowRoot) {
92
+ shadowRoots.push(el.shadowRoot);
93
+ walkDeep(el.shadowRoot, selector, (m) => matches.push(m), (shadow) => shadowRoots.push(shadow));
94
+ }
95
+ walkDeep(el, selector, (m) => matches.push(m), (shadow) => shadowRoots.push(shadow));
96
+ return { matches, shadowRoots };
97
+ }
98
+
99
+ /**
100
+ * While ON, watch root and all shadowRoots under it, notifying on entry/exit of selector-matching elements.
101
+ * If an added element has a new shadowRoot, that shadowRoot is also added to the observation.
102
+ *
103
+ * @param {object} params
104
+ * @param {ParentNode} [params.root=document]
105
+ * @param {string} params.selector
106
+ * @param {(el: Element) => void} params.onAdded
107
+ * @param {(el: Element) => void} params.onRemoved
108
+ * @returns {{ disconnect(): void }}
109
+ */
110
+ export function createMutationWatcher({ root = document, selector, onAdded, onRemoved }) {
111
+ const observed = new Set();
112
+
113
+ const handle = (records) => {
114
+ for (const record of records) {
115
+ // For added nodes, get both "matching elements" and "shadowRoots to start observing" in one traversal per subtree.
116
+ record.addedNodes.forEach((node) => {
117
+ const { matches, shadowRoots } = scanSubtree(node, selector);
118
+ matches.forEach((el) => onAdded(el));
119
+ shadowRoots.forEach(observe);
120
+ });
121
+ record.removedNodes.forEach((node) => {
122
+ scanSubtree(node, selector).matches.forEach((el) => onRemoved(el));
123
+ });
124
+ }
125
+ };
126
+
127
+ const observer = new MutationObserver(handle);
128
+
129
+ function observe(target) {
130
+ if (observed.has(target)) {
131
+ return;
132
+ }
133
+ observed.add(target);
134
+ observer.observe(target, { childList: true, subtree: true });
135
+ }
136
+
137
+ observe(root);
138
+ collectShadowRoots(root).forEach(observe);
139
+
140
+ return {
141
+ disconnect() {
142
+ observer.disconnect();
143
+ observed.clear();
144
+ },
145
+ };
146
+ }
package/src/overlap.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Overlap avoidance between markers (pure function).
3
+ *
4
+ * Takes an array of each marker's "base position" (the center coordinate Floating UI
5
+ * decided on) and returns an array of extra offsets that push overlapping ones apart.
6
+ * Touches no DOM.
7
+ *
8
+ * The algorithm is a simple iterative push-out (a lightweight force-based separation):
9
+ * if two circles are closer than the minimum distance, push them apart in opposite
10
+ * directions. Repeat a few times. Markers are small circles, so a circle-to-circle
11
+ * distance test is enough.
12
+ */
13
+
14
+ /**
15
+ * @param {Array<{x:number,y:number}>} centers base coordinate of each marker center
16
+ * @param {object} [options]
17
+ * @param {number} [options.minDistance] center-to-center distance closer than this counts as overlap
18
+ * @param {number} [options.iterations] number of iterations
19
+ * @returns {Array<{dx:number,dy:number}>} offset to add to each marker
20
+ */
21
+ export function resolveOverlaps(centers, options = {}) {
22
+ const minDistance = options.minDistance ?? 26;
23
+ const iterations = options.iterations ?? 6;
24
+
25
+ // Working positions (base + accumulated offset).
26
+ const positions = centers.map((c) => ({ x: c.x, y: c.y }));
27
+
28
+ for (let iter = 0; iter < iterations; iter++) {
29
+ let moved = false;
30
+
31
+ for (let i = 0; i < positions.length; i++) {
32
+ for (let j = i + 1; j < positions.length; j++) {
33
+ const a = positions[i];
34
+ const b = positions[j];
35
+ let dx = b.x - a.x;
36
+ let dy = b.y - a.y;
37
+ let dist = Math.hypot(dx, dy);
38
+
39
+ if (dist >= minDistance) {
40
+ continue;
41
+ }
42
+
43
+ // If the coordinates are exactly identical, separate in a deterministic direction (horizontal).
44
+ if (dist === 0) {
45
+ dx = 1;
46
+ dy = 0;
47
+ dist = 1;
48
+ }
49
+
50
+ const overlap = (minDistance - dist) / 2;
51
+ const ux = dx / dist;
52
+ const uy = dy / dist;
53
+
54
+ a.x -= ux * overlap;
55
+ a.y -= uy * overlap;
56
+ b.x += ux * overlap;
57
+ b.y += uy * overlap;
58
+ moved = true;
59
+ }
60
+ }
61
+
62
+ if (!moved) {
63
+ break;
64
+ }
65
+ }
66
+
67
+ return positions.map((p, i) => ({
68
+ dx: p.x - centers[i].x,
69
+ dy: p.y - centers[i].y,
70
+ }));
71
+ }
package/src/popup.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The single popup shared across the whole library.
3
+ * Placed on its target (the clicked marker) with Floating UI; at screen edges, flip/shift avoid
4
+ * clipping. While visible it follows via autoUpdate.
5
+ *
6
+ * Accessibility:
7
+ * - On open, move focus to the popup (role=dialog).
8
+ * - On close, return focus to the trigger element (the marker).
9
+ */
10
+ import { createPopup } from './dom-builder.js';
11
+ import { anchorPopup } from './floating.js';
12
+
13
+ /**
14
+ * @param {object} state teardown registry
15
+ * @param {object} [options]
16
+ * @param {() => void} [options.onClose] called when the popup closes (transitions from shown to hidden)
17
+ * @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render]
18
+ * Escape hatch to render the body area with your own DOM node. Return a Node to display it;
19
+ * if nothing is returned, fall back to safe text rendering (textContent). The title is always record.title.
20
+ * Note: the return value is appendChild'd as-is without sanitization, so untrusted data must be neutralized by the caller.
21
+ * @param {import('@floating-ui/dom').Placement} [options.popupPlacement] initial placement (default 'bottom-start')
22
+ */
23
+ export function createPopupController(state, { onClose, render, popupPlacement = 'bottom-start' } = {}) {
24
+ const { root, titleEl, textEl, closeEl } = createPopup();
25
+ document.body.appendChild(root);
26
+
27
+ // The close (×) button. root is removed on teardown, so explicitly detaching the listener isn't needed.
28
+ closeEl.addEventListener('click', () => close());
29
+
30
+ let openId = null;
31
+ let triggerEl = null;
32
+ let anchor = null;
33
+
34
+ function stopAnchor() {
35
+ if (anchor) {
36
+ anchor.cleanup();
37
+ anchor = null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * @param {import('./matcher.js').HelpRecord} record
43
+ * @param {HTMLElement} referenceEl placement reference (the clicked marker element)
44
+ */
45
+ function open(record, referenceEl) {
46
+ titleEl.textContent = record.title;
47
+ // If render exists, replace the body with a custom Node; otherwise fall back to safe text rendering.
48
+ const custom = render ? render(record) : null;
49
+ textEl.textContent = '';
50
+ if (custom) {
51
+ textEl.appendChild(custom);
52
+ } else {
53
+ textEl.textContent = record.text;
54
+ }
55
+ root.style.display = 'block';
56
+ openId = record.id;
57
+ triggerEl = referenceEl;
58
+
59
+ stopAnchor();
60
+ anchor = anchorPopup(referenceEl, root, popupPlacement);
61
+
62
+ // preventScroll: the popup is positioned asynchronously (computePosition().then), so at this
63
+ // point it's still at its stale position; a default focus would scroll toward that, causing a
64
+ // visible jump. flip/shift keep it in the viewport, so suppressing the scroll is safe.
65
+ root.focus({ preventScroll: true });
66
+ }
67
+
68
+ // Reposition immediately, only when open.
69
+ // (Called e.g. right after a marker shifts due to the overlap-avoidance transform.)
70
+ function reposition() {
71
+ if (anchor) {
72
+ anchor.update();
73
+ }
74
+ }
75
+
76
+ function hide() {
77
+ // Call onClose only if it was open (catches both the close-path and teardown-path close routes at one point).
78
+ const wasOpen = openId !== null;
79
+ stopAnchor();
80
+ openId = null;
81
+ triggerEl = null;
82
+ root.style.display = 'none';
83
+ if (wasOpen && onClose) {
84
+ onClose();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Close and return focus.
90
+ * @param {HTMLElement} [focusTarget] explicit focus-return target.
91
+ * If omitted, returns to the trigger element (the marker). In contexts where the trigger
92
+ * disappears (SPA removal), pass another surviving element such as the toggle.
93
+ */
94
+ function close(focusTarget) {
95
+ const returnTo = focusTarget ?? triggerEl;
96
+ hide();
97
+ // Return focus if the target is still in the DOM.
98
+ if (returnTo && returnTo.isConnected && typeof returnTo.focus === 'function') {
99
+ returnTo.focus({ preventScroll: true });
100
+ }
101
+ }
102
+
103
+ state.track(() => {
104
+ hide();
105
+ root.remove();
106
+ });
107
+
108
+ return {
109
+ root,
110
+ isOpen(id) {
111
+ return openId === id;
112
+ },
113
+ getOpenId() {
114
+ return openId;
115
+ },
116
+ open,
117
+ close,
118
+ reposition,
119
+ };
120
+ }
package/src/state.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Registry of teardown callbacks.
3
+ * DOM, listeners, and style changes added while the mode is ON are unwound in
4
+ * reverse order of creation (LIFO), so that dependent cleanups (e.g. detach an
5
+ * internal listener, then remove its element) run in a natural order.
6
+ */
7
+ export function createState() {
8
+ const cleanupFns = [];
9
+
10
+ return {
11
+ track(fn) {
12
+ cleanupFns.push(fn);
13
+ },
14
+ teardownAll() {
15
+ while (cleanupFns.length > 0) {
16
+ const cleanup = cleanupFns.pop();
17
+ cleanup();
18
+ }
19
+ },
20
+ };
21
+ }
package/src/style.js ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The z-index constants help-layer uses, and the CSS it injects.
3
+ * Things that must sit above the blocking layer use Z_TOP (markers); the popup uses Z_POPUP so it
4
+ * always paints in front of the markers (they share a stacking context as <body> children, so a tie
5
+ * would otherwise be decided by DOM order and a remounted marker could cover an open popup).
6
+ * The toggle is made visible through the clip-path "hole", so its z-index is left untouched.
7
+ */
8
+ export const Z_BLOCKING_LAYER = 2147483000;
9
+ export const Z_TOP = 2147483001;
10
+ export const Z_POPUP = 2147483002;
11
+
12
+ const STYLE_ATTR = 'data-help-layer-style';
13
+
14
+ // The theme is fully exposed via CSS custom properties. Users can change the look just by
15
+ // overriding the following variables in host-side CSS (e.g. :root or any scope):
16
+ // --help-layer-marker-size marker diameter (default 22px)
17
+ // --help-layer-marker-bg marker background color (default #2563eb)
18
+ // --help-layer-marker-color marker text color (default #fff)
19
+ // --help-layer-popup-bg popup background color (default #fff)
20
+ // --help-layer-popup-color popup text color (default #1f2933)
21
+ // --help-layer-popup-max-width popup max width (default 280px)
22
+ // --help-layer-popup-max-height body max height (default 50vh, scrolls when exceeded)
23
+ // --help-layer-accent focus ring color (default #1d4ed8)
24
+ // --help-layer-overlay-bg blocking-layer (scrim) background (default transparent; e.g. rgba(0,0,0,0.15))
25
+ // --help-layer-overlay-cursor cursor over the blocked area (default default; e.g. not-allowed / help)
26
+ const CSS = `
27
+ .help-layer-blocking-layer {
28
+ position: fixed;
29
+ inset: 0;
30
+ /* Default transparent (unchanged). Set --help-layer-overlay-bg to tint it into a scrim that signals
31
+ "the host app is inactive". The clip-path hole isn't painted, so the toggle stays untinted. */
32
+ background: var(--help-layer-overlay-bg, transparent);
33
+ /* Cursor over the blocked area only (the toggle shows through the hole and keeps its own cursor).
34
+ e.g. not-allowed / help makes "this won't respond" obvious without needing a tint. */
35
+ cursor: var(--help-layer-overlay-cursor, default);
36
+ z-index: ${Z_BLOCKING_LAYER};
37
+ }
38
+
39
+ .help-layer-marker {
40
+ /* reset of the button element */
41
+ appearance: none;
42
+ -webkit-appearance: none;
43
+ margin: 0;
44
+ padding: 0;
45
+ border: none;
46
+ position: absolute;
47
+ top: 0;
48
+ left: 0;
49
+ width: var(--help-layer-marker-size, 22px);
50
+ height: var(--help-layer-marker-size, 22px);
51
+ border-radius: 50%;
52
+ background: var(--help-layer-marker-bg, #2563eb);
53
+ color: var(--help-layer-marker-color, #fff);
54
+ font-family: sans-serif;
55
+ font-size: 13px;
56
+ font-weight: bold;
57
+ line-height: var(--help-layer-marker-size, 22px);
58
+ text-align: center;
59
+ cursor: pointer;
60
+ user-select: none;
61
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
62
+ z-index: ${Z_TOP};
63
+ }
64
+
65
+ .help-layer-marker:focus-visible {
66
+ outline: 3px solid var(--help-layer-accent, #1d4ed8);
67
+ outline-offset: 2px;
68
+ }
69
+
70
+ .help-layer-popup {
71
+ position: absolute;
72
+ top: 0;
73
+ left: 0;
74
+ display: none;
75
+ max-width: var(--help-layer-popup-max-width, 280px);
76
+ background: var(--help-layer-popup-bg, #fff);
77
+ color: var(--help-layer-popup-color, #1f2933);
78
+ border-radius: 6px;
79
+ padding: 12px 14px;
80
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
81
+ font-family: sans-serif;
82
+ font-size: 13px;
83
+ line-height: 1.5;
84
+ z-index: ${Z_POPUP};
85
+ }
86
+
87
+ .help-layer-popup:focus {
88
+ outline: none;
89
+ }
90
+
91
+ .help-layer-popup:focus-visible {
92
+ outline: 3px solid var(--help-layer-accent, #1d4ed8);
93
+ outline-offset: 2px;
94
+ }
95
+
96
+ .help-layer-popup__title {
97
+ font-weight: bold;
98
+ margin-bottom: 4px;
99
+ /* Reserve space so it doesn't overlap the × button at the top-right. */
100
+ padding-right: 16px;
101
+ }
102
+
103
+ .help-layer-popup__text {
104
+ /* Render the body's \n as line breaks (still textContent, so no XSS risk). */
105
+ white-space: pre-line;
106
+ /* Keep long text from spilling off-screen; only the body scrolls within the popup. */
107
+ max-height: var(--help-layer-popup-max-height, 50vh);
108
+ overflow-y: auto;
109
+ }
110
+
111
+ .help-layer-popup__close {
112
+ /* reset of the button element */
113
+ appearance: none;
114
+ -webkit-appearance: none;
115
+ position: absolute;
116
+ top: 6px;
117
+ right: 6px;
118
+ width: 22px;
119
+ height: 22px;
120
+ padding: 0;
121
+ border: none;
122
+ border-radius: 4px;
123
+ background: transparent;
124
+ color: inherit;
125
+ font-size: 16px;
126
+ line-height: 1;
127
+ cursor: pointer;
128
+ }
129
+
130
+ .help-layer-popup__close:hover {
131
+ background: rgba(0, 0, 0, 0.08);
132
+ }
133
+
134
+ .help-layer-popup__close:focus-visible {
135
+ outline: 2px solid var(--help-layer-accent, #1d4ed8);
136
+ outline-offset: 1px;
137
+ }
138
+
139
+ /*
140
+ * Show an outline on the target element only while the marker is hovered/focused (clarifies "which element this explains").
141
+ * Make only the outline !important so it can beat host-side outline resets.
142
+ */
143
+ .help-layer-target-highlight {
144
+ outline: 2px solid var(--help-layer-accent, #1d4ed8) !important;
145
+ outline-offset: 2px !important;
146
+ }
147
+
148
+ /*
149
+ * Dark-mode defaults. If the user specifies CSS variables, those always win via var(), so here we
150
+ * only swap the dark fallback values (the properties themselves aren't re-declared).
151
+ */
152
+ @media (prefers-color-scheme: dark) {
153
+ .help-layer-popup {
154
+ background: var(--help-layer-popup-bg, #1f2933);
155
+ color: var(--help-layer-popup-color, #e5e7eb);
156
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.55);
157
+ }
158
+ }
159
+ `;
160
+
161
+ /**
162
+ * Inject a <style> tag into head and return that element.
163
+ * @param {string} [nonce] nonce to allow this <style> under a strict CSP (style-src 'nonce-…').
164
+ * The nonce attribute is added only when provided. If omitted, nothing is added (as before).
165
+ */
166
+ export function injectStyles(nonce) {
167
+ const styleEl = document.createElement('style');
168
+ styleEl.setAttribute(STYLE_ATTR, '');
169
+ // Under a CSP running style-src 'nonce-…', only a <style> with a matching nonce is applied.
170
+ if (nonce) {
171
+ styleEl.setAttribute('nonce', nonce);
172
+ }
173
+ styleEl.textContent = CSS;
174
+ document.head.appendChild(styleEl);
175
+ return styleEl;
176
+ }
177
+
178
+ /**
179
+ * Remove the <style> tag injected by injectStyles().
180
+ */
181
+ export function removeStyles(styleEl) {
182
+ styleEl.remove();
183
+ }