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/LICENSE +15 -0
- package/README.ja.md +217 -0
- package/README.md +218 -0
- package/dist/help-layer.esm.js +139 -0
- package/dist/help-layer.esm.js.map +7 -0
- package/dist/help-layer.iife.js +139 -0
- package/dist/help-layer.iife.js.map +7 -0
- package/dist/types/blocking-layer.d.ts +6 -0
- package/dist/types/config.d.ts +44 -0
- package/dist/types/dom-builder.d.ts +16 -0
- package/dist/types/floating.d.ts +58 -0
- package/dist/types/geometry.d.ts +39 -0
- package/dist/types/index.d.ts +59 -0
- package/dist/types/markers.d.ts +24 -0
- package/dist/types/matcher.d.ts +79 -0
- package/dist/types/observer.d.ts +32 -0
- package/dist/types/overlap.d.ts +29 -0
- package/dist/types/popup.d.ts +22 -0
- package/dist/types/state.d.ts +10 -0
- package/dist/types/style.d.ts +20 -0
- package/dist/types/toggle.d.ts +41 -0
- package/package.json +81 -0
- package/src/blocking-layer.js +131 -0
- package/src/config.js +81 -0
- package/src/dom-builder.js +59 -0
- package/src/floating.js +122 -0
- package/src/geometry.js +41 -0
- package/src/index.js +40 -0
- package/src/markers.js +185 -0
- package/src/matcher.js +133 -0
- package/src/observer.js +146 -0
- package/src/overlap.js +71 -0
- package/src/popup.js +120 -0
- package/src/state.js +21 -0
- package/src/style.js +183 -0
- package/src/toggle.js +250 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory functions that create the DOM elements for markers, popups, and the blocking layer.
|
|
3
|
+
* Event wiring and positioning are not done here (that is the caller's responsibility).
|
|
4
|
+
*
|
|
5
|
+
* Accessibility:
|
|
6
|
+
* - Markers are <button> elements so they are focusable and can be activated with Enter/Space.
|
|
7
|
+
* - The popup uses role="dialog" + aria-labelledby (the title element) to describe itself to assistive tech.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const POPUP_TITLE_ID = 'help-layer-popup-title';
|
|
11
|
+
|
|
12
|
+
export function createBlockingLayer() {
|
|
13
|
+
const layer = document.createElement('div');
|
|
14
|
+
layer.className = 'help-layer-blocking-layer';
|
|
15
|
+
return layer;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string} title description title used for the assistive-tech label
|
|
20
|
+
* @param {string} [label] character shown on the marker (default '?'). Visual only; does not affect the aria-label.
|
|
21
|
+
*/
|
|
22
|
+
export function createMarker(title, label = '?') {
|
|
23
|
+
const marker = document.createElement('button');
|
|
24
|
+
marker.type = 'button';
|
|
25
|
+
marker.className = 'help-layer-marker';
|
|
26
|
+
marker.textContent = label;
|
|
27
|
+
marker.setAttribute('aria-label', `Help: ${title}`);
|
|
28
|
+
return marker;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create the single popup shared across the whole library.
|
|
33
|
+
* Also returns references to titleEl/textEl (used to update the content) and the close button closeEl.
|
|
34
|
+
*/
|
|
35
|
+
export function createPopup() {
|
|
36
|
+
const root = document.createElement('div');
|
|
37
|
+
root.className = 'help-layer-popup';
|
|
38
|
+
root.setAttribute('role', 'dialog');
|
|
39
|
+
root.setAttribute('aria-labelledby', POPUP_TITLE_ID);
|
|
40
|
+
root.tabIndex = -1;
|
|
41
|
+
|
|
42
|
+
const titleEl = document.createElement('div');
|
|
43
|
+
titleEl.className = 'help-layer-popup__title';
|
|
44
|
+
titleEl.id = POPUP_TITLE_ID;
|
|
45
|
+
|
|
46
|
+
const textEl = document.createElement('div');
|
|
47
|
+
textEl.className = 'help-layer-popup__text';
|
|
48
|
+
|
|
49
|
+
// Explicit close affordance. Wiring the click is popup.js's job (only element creation here).
|
|
50
|
+
const closeEl = document.createElement('button');
|
|
51
|
+
closeEl.type = 'button';
|
|
52
|
+
closeEl.className = 'help-layer-popup__close';
|
|
53
|
+
closeEl.textContent = '×';
|
|
54
|
+
closeEl.setAttribute('aria-label', 'Close');
|
|
55
|
+
|
|
56
|
+
root.append(titleEl, textEl, closeEl);
|
|
57
|
+
|
|
58
|
+
return { root, titleEl, textEl, closeEl };
|
|
59
|
+
}
|
package/src/floating.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A thin wrapper around Floating UI (@floating-ui/dom).
|
|
3
|
+
* Use of Floating UI is confined to this one file; other modules only call the
|
|
4
|
+
* purpose-specific functions (anchorMarker / anchorPopup / watchReference /
|
|
5
|
+
* makeVirtualElement). That way, if Floating UI is ever swapped out, the blast
|
|
6
|
+
* radius stays limited to here.
|
|
7
|
+
*
|
|
8
|
+
* Floating UI in a nutshell (note for readers unfamiliar with the DOM):
|
|
9
|
+
* - computePosition(reference, floating, options) computes the optimal placement
|
|
10
|
+
* coordinates (x,y) for "this exact moment" and returns them (one-shot).
|
|
11
|
+
* - autoUpdate(reference, floating, update) watches scroll, resize, and element-size
|
|
12
|
+
* changes (internally using ResizeObserver, etc.) and calls update on every change.
|
|
13
|
+
* Calling the returned cleanup stops watching. This is what makes the element "stick"
|
|
14
|
+
* to its target.
|
|
15
|
+
*/
|
|
16
|
+
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
|
17
|
+
|
|
18
|
+
import { docRectToViewportRect } from './geometry.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Create a "virtual reference element" for free-placement items not bound to an element.
|
|
22
|
+
* When getDocRect() returns document coordinates, this converts them to viewport
|
|
23
|
+
* coordinates according to the current scroll. Because autoUpdate re-evaluates on every
|
|
24
|
+
* scroll, the element sticks to the given coordinate while scrolling along with the page.
|
|
25
|
+
* @param {() => {top:number,left:number,width?:number,height?:number}} getDocRect
|
|
26
|
+
*/
|
|
27
|
+
export function makeVirtualElement(getDocRect) {
|
|
28
|
+
return {
|
|
29
|
+
// Tell autoUpdate that this element's ancestor is body (= scroll is watched up to window).
|
|
30
|
+
// Without this the virtual element isn't scroll-watched and won't follow page scroll.
|
|
31
|
+
contextElement: document.body,
|
|
32
|
+
getBoundingClientRect() {
|
|
33
|
+
return docRectToViewportRect(getDocRect(), { x: window.scrollX, y: window.scrollY });
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function place(el, x, y) {
|
|
39
|
+
el.style.left = `${x}px`;
|
|
40
|
+
el.style.top = `${y}px`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Half of the default marker size (22px). The amount used to overlap the marker onto the
|
|
44
|
+
// target's corner with an "inset". (If the marker-size CSS variable is changed, the resulting
|
|
45
|
+
// drift is left as existing behavior = not compensated for here.)
|
|
46
|
+
const MARKER_INSET = 11;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Derive the offset for overlapping the marker onto the target's corner from the placement.
|
|
50
|
+
* mainAxis is always negative (bites inward past the target's edge). crossAxis flips sign by
|
|
51
|
+
* alignment direction: `-end` (right/bottom-aligned) is negative to go inward, `-start`
|
|
52
|
+
* (left/top-aligned) is positive to go inward.
|
|
53
|
+
* @param {string} placement
|
|
54
|
+
*/
|
|
55
|
+
function markerOffset(placement) {
|
|
56
|
+
const isStart = placement.endsWith('-start');
|
|
57
|
+
return { mainAxis: -MARKER_INSET, crossAxis: isStart ? MARKER_INSET : -MARKER_INSET };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Overlap the marker onto a corner of the target (element or virtual element), stick it there, and keep it following.
|
|
62
|
+
* @param {Element|object} reference
|
|
63
|
+
* @param {HTMLElement} markerEl
|
|
64
|
+
* @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
|
|
65
|
+
* @param {import('@floating-ui/dom').Placement} [placement] corner to overlap (top-end/top-start/bottom-end/bottom-start). Default 'top-end'
|
|
66
|
+
* @returns {() => void} cleanup
|
|
67
|
+
*/
|
|
68
|
+
export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end') {
|
|
69
|
+
const update = () => {
|
|
70
|
+
computePosition(reference, markerEl, {
|
|
71
|
+
placement,
|
|
72
|
+
middleware: [offset(markerOffset(placement))],
|
|
73
|
+
}).then(({ x, y }) => {
|
|
74
|
+
place(markerEl, x, y);
|
|
75
|
+
if (onPlaced) {
|
|
76
|
+
onPlaced();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
// animationFrame: true syncs repositioning to the rAF loop. With the default (scroll/resize
|
|
81
|
+
// events only), computePosition resolves asynchronously, so left/top is written the frame after
|
|
82
|
+
// the browser already painted the scroll — the marker lags a frame and visibly jitters.
|
|
83
|
+
return autoUpdate(reference, markerEl, update, { animationFrame: true });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Place the popup below the target, and at screen edges use flip (flip to the opposite side) /
|
|
88
|
+
* shift (nudge) to avoid clipping. Only follows while visible.
|
|
89
|
+
* @param {Element|object} reference
|
|
90
|
+
* @param {HTMLElement} popupEl
|
|
91
|
+
* @param {import('@floating-ui/dom').Placement} [placement] initial placement (Floating UI placement). Default 'bottom-start'
|
|
92
|
+
* @returns {{ update: () => void, cleanup: () => void }}
|
|
93
|
+
* calling update repositions immediately (used for reference-side transform moves that autoUpdate doesn't pick up, etc.).
|
|
94
|
+
*/
|
|
95
|
+
export function anchorPopup(reference, popupEl, placement = 'bottom-start') {
|
|
96
|
+
const update = () => {
|
|
97
|
+
computePosition(reference, popupEl, {
|
|
98
|
+
placement,
|
|
99
|
+
middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
|
|
100
|
+
}).then(({ x, y }) => {
|
|
101
|
+
place(popupEl, x, y);
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
// animationFrame: true for the same smooth-tracking reason as anchorMarker. The reference here is
|
|
105
|
+
// the marker element, which itself moves per frame, so the popup must track per frame to stay glued.
|
|
106
|
+
const cleanup = autoUpdate(reference, popupEl, update, { animationFrame: true });
|
|
107
|
+
return { update, cleanup };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Watch a reference element's position/size changes and call onUpdate on every change.
|
|
112
|
+
* (Used for non-placement purposes, e.g. keeping the blocking layer's clip-path hole following the toggle position.)
|
|
113
|
+
* autoUpdate requires a floating element, so floatingEl is just passed as a dummy that
|
|
114
|
+
* onUpdate doesn't actually position.
|
|
115
|
+
* @param {Element} referenceEl
|
|
116
|
+
* @param {HTMLElement} floatingEl
|
|
117
|
+
* @param {() => void} onUpdate
|
|
118
|
+
* @returns {() => void} cleanup
|
|
119
|
+
*/
|
|
120
|
+
export function watchReference(referenceEl, floatingEl, onUpdate) {
|
|
121
|
+
return autoUpdate(referenceEl, floatingEl, onUpdate);
|
|
122
|
+
}
|
package/src/geometry.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure geometry calculations. Takes no DOM elements, only numbers already read off.
|
|
3
|
+
*
|
|
4
|
+
* Clamping things that overflow the viewport is handled by Floating UI's shift()
|
|
5
|
+
* middleware. toDocumentPosition is used for the virtual-element math of free placement, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Given getBoundingClientRect() values (viewport-relative) and the scroll offset,
|
|
10
|
+
* compute coordinates relative to the whole document.
|
|
11
|
+
*/
|
|
12
|
+
export function toDocumentPosition(rect, scroll) {
|
|
13
|
+
return {
|
|
14
|
+
top: rect.top + scroll.y,
|
|
15
|
+
left: rect.left + scroll.x,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Convert a document-coordinate rect into a viewport-coordinate rect by subtracting
|
|
21
|
+
* the current scroll offset. This is what the getBoundingClientRect of a Floating UI
|
|
22
|
+
* virtual reference element (a free-placement marker) returns.
|
|
23
|
+
* @param {{top:number,left:number,width?:number,height?:number}} docRect
|
|
24
|
+
* @param {{x:number,y:number}} scroll
|
|
25
|
+
*/
|
|
26
|
+
export function docRectToViewportRect(docRect, scroll) {
|
|
27
|
+
const width = docRect.width || 0;
|
|
28
|
+
const height = docRect.height || 0;
|
|
29
|
+
const left = docRect.left - scroll.x;
|
|
30
|
+
const top = docRect.top - scroll.y;
|
|
31
|
+
return {
|
|
32
|
+
x: left,
|
|
33
|
+
y: top,
|
|
34
|
+
left,
|
|
35
|
+
top,
|
|
36
|
+
right: left + width,
|
|
37
|
+
bottom: top + height,
|
|
38
|
+
width,
|
|
39
|
+
height,
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createToggleController } from './toggle.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Initialize the help mode.
|
|
5
|
+
*
|
|
6
|
+
* It can be toggled ON/OFF by clicking the toggle element, and also controlled programmatically
|
|
7
|
+
* via the returned API. If `toggle` is omitted, there's no DOM toggle and it's programmatic-only.
|
|
8
|
+
*
|
|
9
|
+
* @param {object} options
|
|
10
|
+
* @param {import('./config.js').HelpConfig} options.config - configuration that specifies targets by data-help-id or position.
|
|
11
|
+
* Elements not in config can still be targets via the data-help-title / data-help-text inline definition (config wins)
|
|
12
|
+
* @param {string|HTMLElement} [options.toggle] - toggle element that switches ON/OFF (CSS selector string or element). Optional
|
|
13
|
+
* @param {() => void} [options.onEnable] - called right after the mode is turned ON
|
|
14
|
+
* @param {() => void} [options.onDisable] - called right after the mode is turned OFF
|
|
15
|
+
* @param {(record: import('./matcher.js').HelpRecord) => void} [options.onOpen] - called when a description popup is opened
|
|
16
|
+
* @param {() => void} [options.onClose] - called when a description popup is closed
|
|
17
|
+
* @param {boolean} [options.silent] - suppress the warning log for unregistered keys
|
|
18
|
+
* @param {string} [options.attribute] - attribute name marking targets (default 'data-help-id')
|
|
19
|
+
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render] - render the popup body with your own DOM.
|
|
20
|
+
* Return a Node to display it; if nothing is returned, fall back to safe text display (the title is always record.title).
|
|
21
|
+
* ⚠️ The return value is inserted as-is without sanitization. If it contains untrusted data, neutralize it on the caller side (XSS prevention)
|
|
22
|
+
* @param {string} [options.markerLabel] - character shown on the marker (default '?')
|
|
23
|
+
* @param {import('@floating-ui/dom').Placement} [options.markerPlacement] - corner to overlap the marker onto (default 'top-end')
|
|
24
|
+
* @param {import('@floating-ui/dom').Placement} [options.popupPlacement] - initial popup placement (default 'bottom-start')
|
|
25
|
+
* @param {string} [options.nonce] - nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
|
|
26
|
+
* @returns {{
|
|
27
|
+
* enable(): void,
|
|
28
|
+
* disable(): void,
|
|
29
|
+
* toggle(): void,
|
|
30
|
+
* isActive(): boolean,
|
|
31
|
+
* open(key: string): void,
|
|
32
|
+
* close(): void,
|
|
33
|
+
* update(config: import('./config.js').HelpConfig): void,
|
|
34
|
+
* destroy(): void,
|
|
35
|
+
* }} a handle to control the mode and fully clean up at the end.
|
|
36
|
+
* open(key) opens the description for the given key (auto-enables when OFF). close() closes the open description.
|
|
37
|
+
*/
|
|
38
|
+
export function initHelpLayer(options) {
|
|
39
|
+
return createToggleController(options);
|
|
40
|
+
}
|
package/src/markers.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker manager.
|
|
3
|
+
* Markers can be dynamically mounted/unmounted per help record (SPA dynamic-element support).
|
|
4
|
+
* Each marker keeps following its target (an element, or the virtual element of a free placement)
|
|
5
|
+
* via Floating UI's autoUpdate. On every finalized placement it triggers the overlap-avoidance pass,
|
|
6
|
+
* debounced with rAF.
|
|
7
|
+
*
|
|
8
|
+
* Marker identifier (id):
|
|
9
|
+
* - element-bound: the target element itself (distinguishes multiple elements with the same data-help-id)
|
|
10
|
+
* - free placement: the config key string
|
|
11
|
+
*/
|
|
12
|
+
import { createMarker } from './dom-builder.js';
|
|
13
|
+
import { anchorMarker, makeVirtualElement } from './floating.js';
|
|
14
|
+
import { resolveOverlaps } from './overlap.js';
|
|
15
|
+
|
|
16
|
+
// Temporary class added to the target element only while the marker is hovered/focused (matches the style.js definition).
|
|
17
|
+
const TARGET_HIGHLIGHT_CLASS = 'help-layer-target-highlight';
|
|
18
|
+
|
|
19
|
+
/** @param {import('./matcher.js').HelpRecord} record */
|
|
20
|
+
function referenceFor(record) {
|
|
21
|
+
if (record.kind === 'free') {
|
|
22
|
+
return makeVirtualElement(() => ({
|
|
23
|
+
top: record.position.top,
|
|
24
|
+
left: record.position.left,
|
|
25
|
+
width: 0,
|
|
26
|
+
height: 0,
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
return record.target;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {object} state teardown registry
|
|
34
|
+
* @param {object} options
|
|
35
|
+
* @param {(record: import('./matcher.js').HelpRecord, markerEl: HTMLElement) => void} options.onMarkerClick
|
|
36
|
+
* @param {() => void} [options.onOverlapResolved]
|
|
37
|
+
* @param {string} [options.markerLabel] character shown on the marker (default '?')
|
|
38
|
+
* @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
|
|
39
|
+
*/
|
|
40
|
+
export function createMarkerManager(state, {
|
|
41
|
+
onMarkerClick,
|
|
42
|
+
onOverlapResolved,
|
|
43
|
+
markerLabel = '?',
|
|
44
|
+
markerPlacement = 'top-end',
|
|
45
|
+
}) {
|
|
46
|
+
/** @type {Map<Element|string, {record:import('./matcher.js').HelpRecord, el:HTMLElement, cleanup:() => void}>} */
|
|
47
|
+
const markers = new Map();
|
|
48
|
+
let rafId = null;
|
|
49
|
+
// Don't schedule a new rAF during teardown (prevents a frame lingering after teardown).
|
|
50
|
+
let tornDown = false;
|
|
51
|
+
|
|
52
|
+
function runOverlapPass() {
|
|
53
|
+
rafId = null;
|
|
54
|
+
const entries = [...markers.values()];
|
|
55
|
+
// With one marker or fewer, overlap is impossible. Skip getBoundingClientRect (forced reflow)
|
|
56
|
+
// and the O(n^2) push-out math entirely (avoids a per-frame reflow while scrolling on screens
|
|
57
|
+
// with few targets). However, right after dropping from 2 to 1, if the remaining one still has
|
|
58
|
+
// a leftover push-out transform, clear it and let an open popup follow that move (in steady
|
|
59
|
+
// state, with an empty transform, do nothing).
|
|
60
|
+
if (entries.length <= 1) {
|
|
61
|
+
const el = entries.length === 1 ? entries[0].el : null;
|
|
62
|
+
if (el && el.style.transform) {
|
|
63
|
+
el.style.transform = '';
|
|
64
|
+
if (onOverlapResolved) {
|
|
65
|
+
onOverlapResolved();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Clear the accumulated transform to measure base centers, then reapply after resolving overlaps.
|
|
72
|
+
entries.forEach((e) => { e.el.style.transform = ''; });
|
|
73
|
+
const centers = entries.map((e) => {
|
|
74
|
+
const r = e.el.getBoundingClientRect();
|
|
75
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
76
|
+
});
|
|
77
|
+
const offsets = resolveOverlaps(centers);
|
|
78
|
+
entries.forEach((e, i) => {
|
|
79
|
+
const { dx, dy } = offsets[i];
|
|
80
|
+
e.el.style.transform = (dx || dy) ? `translate(${dx}px, ${dy}px)` : '';
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Marker positions moved, so give an open popup etc. the chance to follow.
|
|
84
|
+
if (onOverlapResolved) {
|
|
85
|
+
onOverlapResolved();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function scheduleOverlapPass() {
|
|
90
|
+
if (rafId !== null || tornDown) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
rafId = requestAnimationFrame(runOverlapPass);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {import('./matcher.js').HelpRecord} record */
|
|
97
|
+
function mount(record) {
|
|
98
|
+
if (markers.has(record.id)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const el = createMarker(record.title, markerLabel);
|
|
103
|
+
document.body.appendChild(el);
|
|
104
|
+
|
|
105
|
+
const handleClick = () => onMarkerClick(record, el);
|
|
106
|
+
el.addEventListener('click', handleClick);
|
|
107
|
+
|
|
108
|
+
const cleanupAnchor = anchorMarker(referenceFor(record), el, scheduleOverlapPass, markerPlacement);
|
|
109
|
+
|
|
110
|
+
// Target-element highlight (element-bound only; free placement has no target, so skip).
|
|
111
|
+
// Show an outline on the target only while the marker is hovered/focused, to make clear "which element this explains".
|
|
112
|
+
const target = record.kind === 'element' ? record.target : null;
|
|
113
|
+
const addHighlight = () => target && target.classList.add(TARGET_HIGHLIGHT_CLASS);
|
|
114
|
+
const removeHighlight = () => target && target.classList.remove(TARGET_HIGHLIGHT_CLASS);
|
|
115
|
+
if (target) {
|
|
116
|
+
el.addEventListener('mouseenter', addHighlight);
|
|
117
|
+
el.addEventListener('mouseleave', removeHighlight);
|
|
118
|
+
el.addEventListener('focus', addHighlight);
|
|
119
|
+
el.addEventListener('blur', removeHighlight);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let done = false;
|
|
123
|
+
const cleanup = () => {
|
|
124
|
+
if (done) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
done = true;
|
|
128
|
+
cleanupAnchor();
|
|
129
|
+
el.removeEventListener('click', handleClick);
|
|
130
|
+
if (target) {
|
|
131
|
+
el.removeEventListener('mouseenter', addHighlight);
|
|
132
|
+
el.removeEventListener('mouseleave', removeHighlight);
|
|
133
|
+
el.removeEventListener('focus', addHighlight);
|
|
134
|
+
el.removeEventListener('blur', removeHighlight);
|
|
135
|
+
removeHighlight(); // don't leave the highlight on the target if unmounted while highlighted
|
|
136
|
+
}
|
|
137
|
+
el.remove();
|
|
138
|
+
markers.delete(record.id);
|
|
139
|
+
scheduleOverlapPass();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
markers.set(record.id, { record, el, cleanup });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unmount(id) {
|
|
146
|
+
const entry = markers.get(id);
|
|
147
|
+
if (entry) {
|
|
148
|
+
entry.cleanup();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function mountAll(records) {
|
|
153
|
+
records.forEach(mount);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Register a single teardown for the whole manager with state
|
|
157
|
+
// (individual mount/unmount happen many times during a session, so they're bundled here).
|
|
158
|
+
state.track(() => {
|
|
159
|
+
tornDown = true;
|
|
160
|
+
if (rafId !== null) {
|
|
161
|
+
cancelAnimationFrame(rafId);
|
|
162
|
+
rafId = null;
|
|
163
|
+
}
|
|
164
|
+
[...markers.values()].forEach((entry) => entry.cleanup());
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
mount,
|
|
169
|
+
unmount,
|
|
170
|
+
mountAll,
|
|
171
|
+
has(id) {
|
|
172
|
+
return markers.has(id);
|
|
173
|
+
},
|
|
174
|
+
// Return the first entry matching the config key (either element-bound or free placement).
|
|
175
|
+
// Used by the programmatic open(key).
|
|
176
|
+
findByKey(key) {
|
|
177
|
+
for (const entry of markers.values()) {
|
|
178
|
+
if (entry.record.key === key) {
|
|
179
|
+
return entry;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
package/src/matcher.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map the help-item array returned by normalizeConfig() onto actual DOM elements or free
|
|
3
|
+
* placements, producing the "help records" the marker manager works with. Reads the DOM only;
|
|
4
|
+
* never writes.
|
|
5
|
+
*
|
|
6
|
+
* Behavior of this module:
|
|
7
|
+
* - Searches with queryAllDeep for Shadow DOM support.
|
|
8
|
+
* - Emits a marker for each of multiple elements sharing the same data-help-id (correct in practice).
|
|
9
|
+
* Because of that, an element-bound record uses "the element itself" as its id (identity).
|
|
10
|
+
* - A free-placement record uses the config key as its id.
|
|
11
|
+
*
|
|
12
|
+
* Resolution order for title/text:
|
|
13
|
+
* - First look up config by the `data-help-id` value (config always wins).
|
|
14
|
+
* - If config has no match, use the element's `data-help-title` / `data-help-text` as an inline definition.
|
|
15
|
+
* This lets you adopt the library with markup alone, without a config object.
|
|
16
|
+
*/
|
|
17
|
+
import { queryAllDeep } from './observer.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One marker's worth of "help record". Produced by matcher; consumed by markers/popup/toggle/index — the shared contract.
|
|
21
|
+
* Other modules reference it via `import('./matcher.js').HelpRecord` (the same style as config.js's HelpConfig).
|
|
22
|
+
* @typedef {object} HelpRecord
|
|
23
|
+
* @property {Element|string} id for element-bound, the target element itself; for free placement, the config key string
|
|
24
|
+
* @property {'element'|'free'} kind
|
|
25
|
+
* @property {string|null} key config key (null for an inline-definition-only element)
|
|
26
|
+
* @property {string} title
|
|
27
|
+
* @property {string} text
|
|
28
|
+
* @property {Element} [target] the target element when kind:'element'
|
|
29
|
+
* @property {{top:number,left:number}} [position] the placement coordinate when kind:'free'
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
// Attribute names used for inline definitions (fixed defaults so as not to grow the API).
|
|
33
|
+
export const TITLE_ATTR = 'data-help-title';
|
|
34
|
+
export const TEXT_ATTR = 'data-help-text';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build the selector to scan. In addition to elements with `data-help-id` (default), also pick up
|
|
38
|
+
* elements that only have an inline definition (`data-help-title`).
|
|
39
|
+
* A single source of truth so collectElementRecords and the MutationObserver share the same condition.
|
|
40
|
+
* @param {string} [attribute] attribute name marking targets (default 'data-help-id')
|
|
41
|
+
*/
|
|
42
|
+
export function targetSelector(attribute = 'data-help-id') {
|
|
43
|
+
return `[${attribute}], [${TITLE_ATTR}]`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Turn element-bound items into a key->item Map. */
|
|
47
|
+
export function elementConfigMap(items) {
|
|
48
|
+
const map = new Map();
|
|
49
|
+
for (const item of items) {
|
|
50
|
+
if (item.kind === 'element') {
|
|
51
|
+
map.set(item.key, item);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return map;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Turn free-placement items into records.
|
|
59
|
+
* @returns {HelpRecord[]}
|
|
60
|
+
*/
|
|
61
|
+
export function freeRecords(items) {
|
|
62
|
+
return items
|
|
63
|
+
.filter((item) => item.kind === 'free')
|
|
64
|
+
.map((item) => ({
|
|
65
|
+
id: item.key,
|
|
66
|
+
kind: 'free',
|
|
67
|
+
key: item.key,
|
|
68
|
+
title: item.title,
|
|
69
|
+
text: item.text,
|
|
70
|
+
position: item.position,
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the help record for a single element. title/text prefer config; if absent, fall back to
|
|
76
|
+
* the element's data-help-title / data-help-text (inline definition). If neither source yields
|
|
77
|
+
* both title and text, return null (not a target).
|
|
78
|
+
* (Used by both the initial scan and SPA dynamic additions.)
|
|
79
|
+
* @param {string} [attribute] attribute name marking targets (default 'data-help-id')
|
|
80
|
+
* @returns {HelpRecord|null}
|
|
81
|
+
*/
|
|
82
|
+
export function recordForElement(el, configMap, attribute = 'data-help-id') {
|
|
83
|
+
const key = el.getAttribute(attribute);
|
|
84
|
+
// config wins. If there's no matching key, fall back to the inline attributes.
|
|
85
|
+
const item = key != null ? configMap.get(key) : undefined;
|
|
86
|
+
const title = item ? item.title : el.getAttribute(TITLE_ATTR);
|
|
87
|
+
const text = item ? item.text : el.getAttribute(TEXT_ATTR);
|
|
88
|
+
// If both title and text aren't present, it's not a target (treated as unregistered).
|
|
89
|
+
if (!title || !text) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
id: el,
|
|
94
|
+
kind: 'element',
|
|
95
|
+
key,
|
|
96
|
+
title,
|
|
97
|
+
text,
|
|
98
|
+
target: el,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Scan target-attribute elements under root (including Shadow DOM) and collect element-bound records.
|
|
104
|
+
* Targets not in config are warned about and ignored (non-fatal). silent:true suppresses the warning.
|
|
105
|
+
* @param {object[]} items
|
|
106
|
+
* @param {ParentNode} [root]
|
|
107
|
+
* @param {object} [options]
|
|
108
|
+
* @param {boolean} [options.silent] don't warn on unregistered keys
|
|
109
|
+
* @param {string} [options.attribute] attribute name marking targets (default 'data-help-id')
|
|
110
|
+
* @returns {HelpRecord[]}
|
|
111
|
+
*/
|
|
112
|
+
export function collectElementRecords(items, root = document, { silent = false, attribute = 'data-help-id' } = {}) {
|
|
113
|
+
const configMap = elementConfigMap(items);
|
|
114
|
+
const records = [];
|
|
115
|
+
|
|
116
|
+
queryAllDeep(root, targetSelector(attribute)).forEach((el) => {
|
|
117
|
+
const record = recordForElement(el, configMap, attribute);
|
|
118
|
+
if (!record) {
|
|
119
|
+
if (!silent) {
|
|
120
|
+
const key = el.getAttribute(attribute);
|
|
121
|
+
console.warn(
|
|
122
|
+
key != null
|
|
123
|
+
? `[help-layer] element with ${attribute}="${key}" has no matching helpConfig entry or inline ${TITLE_ATTR}/${TEXT_ATTR}`
|
|
124
|
+
: `[help-layer] element needs both ${TITLE_ATTR} and ${TEXT_ATTR} (or a ${attribute} matching helpConfig)`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
records.push(record);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return records;
|
|
133
|
+
}
|