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,39 @@
|
|
|
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
|
+
* Given getBoundingClientRect() values (viewport-relative) and the scroll offset,
|
|
9
|
+
* compute coordinates relative to the whole document.
|
|
10
|
+
*/
|
|
11
|
+
export function toDocumentPosition(rect: any, scroll: any): {
|
|
12
|
+
top: any;
|
|
13
|
+
left: any;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Convert a document-coordinate rect into a viewport-coordinate rect by subtracting
|
|
17
|
+
* the current scroll offset. This is what the getBoundingClientRect of a Floating UI
|
|
18
|
+
* virtual reference element (a free-placement marker) returns.
|
|
19
|
+
* @param {{top:number,left:number,width?:number,height?:number}} docRect
|
|
20
|
+
* @param {{x:number,y:number}} scroll
|
|
21
|
+
*/
|
|
22
|
+
export function docRectToViewportRect(docRect: {
|
|
23
|
+
top: number;
|
|
24
|
+
left: number;
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
}, scroll: {
|
|
28
|
+
x: number;
|
|
29
|
+
y: number;
|
|
30
|
+
}): {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
left: number;
|
|
34
|
+
top: number;
|
|
35
|
+
right: number;
|
|
36
|
+
bottom: number;
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Initialize the help mode.
|
|
3
|
+
*
|
|
4
|
+
* It can be toggled ON/OFF by clicking the toggle element, and also controlled programmatically
|
|
5
|
+
* via the returned API. If `toggle` is omitted, there's no DOM toggle and it's programmatic-only.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} options
|
|
8
|
+
* @param {import('./config.js').HelpConfig} options.config - configuration that specifies targets by data-help-id or position.
|
|
9
|
+
* Elements not in config can still be targets via the data-help-title / data-help-text inline definition (config wins)
|
|
10
|
+
* @param {string|HTMLElement} [options.toggle] - toggle element that switches ON/OFF (CSS selector string or element). Optional
|
|
11
|
+
* @param {() => void} [options.onEnable] - called right after the mode is turned ON
|
|
12
|
+
* @param {() => void} [options.onDisable] - called right after the mode is turned OFF
|
|
13
|
+
* @param {(record: import('./matcher.js').HelpRecord) => void} [options.onOpen] - called when a description popup is opened
|
|
14
|
+
* @param {() => void} [options.onClose] - called when a description popup is closed
|
|
15
|
+
* @param {boolean} [options.silent] - suppress the warning log for unregistered keys
|
|
16
|
+
* @param {string} [options.attribute] - attribute name marking targets (default 'data-help-id')
|
|
17
|
+
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render] - render the popup body with your own DOM.
|
|
18
|
+
* Return a Node to display it; if nothing is returned, fall back to safe text display (the title is always record.title).
|
|
19
|
+
* ⚠️ The return value is inserted as-is without sanitization. If it contains untrusted data, neutralize it on the caller side (XSS prevention)
|
|
20
|
+
* @param {string} [options.markerLabel] - character shown on the marker (default '?')
|
|
21
|
+
* @param {import('@floating-ui/dom').Placement} [options.markerPlacement] - corner to overlap the marker onto (default 'top-end')
|
|
22
|
+
* @param {import('@floating-ui/dom').Placement} [options.popupPlacement] - initial popup placement (default 'bottom-start')
|
|
23
|
+
* @param {string} [options.nonce] - nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
|
|
24
|
+
* @returns {{
|
|
25
|
+
* enable(): void,
|
|
26
|
+
* disable(): void,
|
|
27
|
+
* toggle(): void,
|
|
28
|
+
* isActive(): boolean,
|
|
29
|
+
* open(key: string): void,
|
|
30
|
+
* close(): void,
|
|
31
|
+
* update(config: import('./config.js').HelpConfig): void,
|
|
32
|
+
* destroy(): void,
|
|
33
|
+
* }} a handle to control the mode and fully clean up at the end.
|
|
34
|
+
* open(key) opens the description for the given key (auto-enables when OFF). close() closes the open description.
|
|
35
|
+
*/
|
|
36
|
+
export function initHelpLayer(options: {
|
|
37
|
+
config: import("./config.js").HelpConfig;
|
|
38
|
+
toggle?: string | HTMLElement;
|
|
39
|
+
onEnable?: () => void;
|
|
40
|
+
onDisable?: () => void;
|
|
41
|
+
onOpen?: (record: import("./matcher.js").HelpRecord) => void;
|
|
42
|
+
onClose?: () => void;
|
|
43
|
+
silent?: boolean;
|
|
44
|
+
attribute?: string;
|
|
45
|
+
render?: (record: import("./matcher.js").HelpRecord) => (Node | null | undefined);
|
|
46
|
+
markerLabel?: string;
|
|
47
|
+
markerPlacement?: import("@floating-ui/dom").Placement;
|
|
48
|
+
popupPlacement?: import("@floating-ui/dom").Placement;
|
|
49
|
+
nonce?: string;
|
|
50
|
+
}): {
|
|
51
|
+
enable(): void;
|
|
52
|
+
disable(): void;
|
|
53
|
+
toggle(): void;
|
|
54
|
+
isActive(): boolean;
|
|
55
|
+
open(key: string): void;
|
|
56
|
+
close(): void;
|
|
57
|
+
update(config: import("./config.js").HelpConfig): void;
|
|
58
|
+
destroy(): void;
|
|
59
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} state teardown registry
|
|
3
|
+
* @param {object} options
|
|
4
|
+
* @param {(record: import('./matcher.js').HelpRecord, markerEl: HTMLElement) => void} options.onMarkerClick
|
|
5
|
+
* @param {() => void} [options.onOverlapResolved]
|
|
6
|
+
* @param {string} [options.markerLabel] character shown on the marker (default '?')
|
|
7
|
+
* @param {import('@floating-ui/dom').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
|
|
8
|
+
*/
|
|
9
|
+
export function createMarkerManager(state: object, { onMarkerClick, onOverlapResolved, markerLabel, markerPlacement, }: {
|
|
10
|
+
onMarkerClick: (record: import("./matcher.js").HelpRecord, markerEl: HTMLElement) => void;
|
|
11
|
+
onOverlapResolved?: () => void;
|
|
12
|
+
markerLabel?: string;
|
|
13
|
+
markerPlacement?: import("@floating-ui/dom").Placement;
|
|
14
|
+
}): {
|
|
15
|
+
mount: (record: import("./matcher.js").HelpRecord) => void;
|
|
16
|
+
unmount: (id: any) => void;
|
|
17
|
+
mountAll: (records: any) => void;
|
|
18
|
+
has(id: any): boolean;
|
|
19
|
+
findByKey(key: any): {
|
|
20
|
+
record: import("./matcher.js").HelpRecord;
|
|
21
|
+
el: HTMLElement;
|
|
22
|
+
cleanup: () => void;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the selector to scan. In addition to elements with `data-help-id` (default), also pick up
|
|
3
|
+
* elements that only have an inline definition (`data-help-title`).
|
|
4
|
+
* A single source of truth so collectElementRecords and the MutationObserver share the same condition.
|
|
5
|
+
* @param {string} [attribute] attribute name marking targets (default 'data-help-id')
|
|
6
|
+
*/
|
|
7
|
+
export function targetSelector(attribute?: string): string;
|
|
8
|
+
/** Turn element-bound items into a key->item Map. */
|
|
9
|
+
export function elementConfigMap(items: any): Map<any, any>;
|
|
10
|
+
/**
|
|
11
|
+
* Turn free-placement items into records.
|
|
12
|
+
* @returns {HelpRecord[]}
|
|
13
|
+
*/
|
|
14
|
+
export function freeRecords(items: any): HelpRecord[];
|
|
15
|
+
/**
|
|
16
|
+
* Build the help record for a single element. title/text prefer config; if absent, fall back to
|
|
17
|
+
* the element's data-help-title / data-help-text (inline definition). If neither source yields
|
|
18
|
+
* both title and text, return null (not a target).
|
|
19
|
+
* (Used by both the initial scan and SPA dynamic additions.)
|
|
20
|
+
* @param {string} [attribute] attribute name marking targets (default 'data-help-id')
|
|
21
|
+
* @returns {HelpRecord|null}
|
|
22
|
+
*/
|
|
23
|
+
export function recordForElement(el: any, configMap: any, attribute?: string): HelpRecord | null;
|
|
24
|
+
/**
|
|
25
|
+
* Scan target-attribute elements under root (including Shadow DOM) and collect element-bound records.
|
|
26
|
+
* Targets not in config are warned about and ignored (non-fatal). silent:true suppresses the warning.
|
|
27
|
+
* @param {object[]} items
|
|
28
|
+
* @param {ParentNode} [root]
|
|
29
|
+
* @param {object} [options]
|
|
30
|
+
* @param {boolean} [options.silent] don't warn on unregistered keys
|
|
31
|
+
* @param {string} [options.attribute] attribute name marking targets (default 'data-help-id')
|
|
32
|
+
* @returns {HelpRecord[]}
|
|
33
|
+
*/
|
|
34
|
+
export function collectElementRecords(items: object[], root?: ParentNode, { silent, attribute }?: {
|
|
35
|
+
silent?: boolean;
|
|
36
|
+
attribute?: string;
|
|
37
|
+
}): HelpRecord[];
|
|
38
|
+
/**
|
|
39
|
+
* One marker's worth of "help record". Produced by matcher; consumed by markers/popup/toggle/index — the shared contract.
|
|
40
|
+
* Other modules reference it via `import('./matcher.js').HelpRecord` (the same style as config.js's HelpConfig).
|
|
41
|
+
* @typedef {object} HelpRecord
|
|
42
|
+
* @property {Element|string} id for element-bound, the target element itself; for free placement, the config key string
|
|
43
|
+
* @property {'element'|'free'} kind
|
|
44
|
+
* @property {string|null} key config key (null for an inline-definition-only element)
|
|
45
|
+
* @property {string} title
|
|
46
|
+
* @property {string} text
|
|
47
|
+
* @property {Element} [target] the target element when kind:'element'
|
|
48
|
+
* @property {{top:number,left:number}} [position] the placement coordinate when kind:'free'
|
|
49
|
+
*/
|
|
50
|
+
export const TITLE_ATTR: "data-help-title";
|
|
51
|
+
export const TEXT_ATTR: "data-help-text";
|
|
52
|
+
/**
|
|
53
|
+
* One marker's worth of "help record". Produced by matcher; consumed by markers/popup/toggle/index — the shared contract.
|
|
54
|
+
* Other modules reference it via `import('./matcher.js').HelpRecord` (the same style as config.js's HelpConfig).
|
|
55
|
+
*/
|
|
56
|
+
export type HelpRecord = {
|
|
57
|
+
/**
|
|
58
|
+
* for element-bound, the target element itself; for free placement, the config key string
|
|
59
|
+
*/
|
|
60
|
+
id: Element | string;
|
|
61
|
+
kind: "element" | "free";
|
|
62
|
+
/**
|
|
63
|
+
* config key (null for an inline-definition-only element)
|
|
64
|
+
*/
|
|
65
|
+
key: string | null;
|
|
66
|
+
title: string;
|
|
67
|
+
text: string;
|
|
68
|
+
/**
|
|
69
|
+
* the target element when kind:'element'
|
|
70
|
+
*/
|
|
71
|
+
target?: Element;
|
|
72
|
+
/**
|
|
73
|
+
* the placement coordinate when kind:'free'
|
|
74
|
+
*/
|
|
75
|
+
position?: {
|
|
76
|
+
top: number;
|
|
77
|
+
left: number;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect every element under root (including inside open shadowRoots) matching selector.
|
|
3
|
+
* @param {ParentNode} root
|
|
4
|
+
* @param {string} selector
|
|
5
|
+
* @returns {Element[]}
|
|
6
|
+
*/
|
|
7
|
+
export function queryAllDeep(root: ParentNode, selector: string): Element[];
|
|
8
|
+
/**
|
|
9
|
+
* Collect every open shadowRoot under root (excluding root itself).
|
|
10
|
+
* @param {ParentNode} root
|
|
11
|
+
* @returns {ShadowRoot[]}
|
|
12
|
+
*/
|
|
13
|
+
export function collectShadowRoots(root: ParentNode): ShadowRoot[];
|
|
14
|
+
/**
|
|
15
|
+
* While ON, watch root and all shadowRoots under it, notifying on entry/exit of selector-matching elements.
|
|
16
|
+
* If an added element has a new shadowRoot, that shadowRoot is also added to the observation.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} params
|
|
19
|
+
* @param {ParentNode} [params.root=document]
|
|
20
|
+
* @param {string} params.selector
|
|
21
|
+
* @param {(el: Element) => void} params.onAdded
|
|
22
|
+
* @param {(el: Element) => void} params.onRemoved
|
|
23
|
+
* @returns {{ disconnect(): void }}
|
|
24
|
+
*/
|
|
25
|
+
export function createMutationWatcher({ root, selector, onAdded, onRemoved }: {
|
|
26
|
+
root?: ParentNode;
|
|
27
|
+
selector: string;
|
|
28
|
+
onAdded: (el: Element) => void;
|
|
29
|
+
onRemoved: (el: Element) => void;
|
|
30
|
+
}): {
|
|
31
|
+
disconnect(): void;
|
|
32
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
* @param {Array<{x:number,y:number}>} centers base coordinate of each marker center
|
|
15
|
+
* @param {object} [options]
|
|
16
|
+
* @param {number} [options.minDistance] center-to-center distance closer than this counts as overlap
|
|
17
|
+
* @param {number} [options.iterations] number of iterations
|
|
18
|
+
* @returns {Array<{dx:number,dy:number}>} offset to add to each marker
|
|
19
|
+
*/
|
|
20
|
+
export function resolveOverlaps(centers: Array<{
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
}>, options?: {
|
|
24
|
+
minDistance?: number;
|
|
25
|
+
iterations?: number;
|
|
26
|
+
}): Array<{
|
|
27
|
+
dx: number;
|
|
28
|
+
dy: number;
|
|
29
|
+
}>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} state teardown registry
|
|
3
|
+
* @param {object} [options]
|
|
4
|
+
* @param {() => void} [options.onClose] called when the popup closes (transitions from shown to hidden)
|
|
5
|
+
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render]
|
|
6
|
+
* Escape hatch to render the body area with your own DOM node. Return a Node to display it;
|
|
7
|
+
* if nothing is returned, fall back to safe text rendering (textContent). The title is always record.title.
|
|
8
|
+
* Note: the return value is appendChild'd as-is without sanitization, so untrusted data must be neutralized by the caller.
|
|
9
|
+
* @param {import('@floating-ui/dom').Placement} [options.popupPlacement] initial placement (default 'bottom-start')
|
|
10
|
+
*/
|
|
11
|
+
export function createPopupController(state: object, { onClose, render, popupPlacement }?: {
|
|
12
|
+
onClose?: () => void;
|
|
13
|
+
render?: (record: import("./matcher.js").HelpRecord) => (Node | null | undefined);
|
|
14
|
+
popupPlacement?: import("@floating-ui/dom").Placement;
|
|
15
|
+
}): {
|
|
16
|
+
root: HTMLDivElement;
|
|
17
|
+
isOpen(id: any): boolean;
|
|
18
|
+
getOpenId(): any;
|
|
19
|
+
open: (record: import("./matcher.js").HelpRecord, referenceEl: HTMLElement) => void;
|
|
20
|
+
close: (focusTarget?: HTMLElement) => void;
|
|
21
|
+
reposition: () => void;
|
|
22
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
track(fn: any): void;
|
|
9
|
+
teardownAll(): void;
|
|
10
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inject a <style> tag into head and return that element.
|
|
3
|
+
* @param {string} [nonce] nonce to allow this <style> under a strict CSP (style-src 'nonce-…').
|
|
4
|
+
* The nonce attribute is added only when provided. If omitted, nothing is added (as before).
|
|
5
|
+
*/
|
|
6
|
+
export function injectStyles(nonce?: string): HTMLStyleElement;
|
|
7
|
+
/**
|
|
8
|
+
* Remove the <style> tag injected by injectStyles().
|
|
9
|
+
*/
|
|
10
|
+
export function removeStyles(styleEl: any): void;
|
|
11
|
+
/**
|
|
12
|
+
* The z-index constants help-layer uses, and the CSS it injects.
|
|
13
|
+
* Things that must sit above the blocking layer use Z_TOP (markers); the popup uses Z_POPUP so it
|
|
14
|
+
* always paints in front of the markers (they share a stacking context as <body> children, so a tie
|
|
15
|
+
* would otherwise be decided by DOM order and a remounted marker could cover an open popup).
|
|
16
|
+
* The toggle is made visible through the clip-path "hole", so its z-index is left untouched.
|
|
17
|
+
*/
|
|
18
|
+
export const Z_BLOCKING_LAYER: 2147483000;
|
|
19
|
+
export const Z_TOP: 2147483001;
|
|
20
|
+
export const Z_POPUP: 2147483002;
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
|
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-…')
|
|
17
|
+
*/
|
|
18
|
+
export function createToggleController({ config, toggle, onEnable, onDisable, onOpen, onClose, silent, attribute, render, markerLabel, markerPlacement, popupPlacement, nonce, }: {
|
|
19
|
+
config: object;
|
|
20
|
+
toggle?: string | HTMLElement;
|
|
21
|
+
onEnable?: () => void;
|
|
22
|
+
onDisable?: () => void;
|
|
23
|
+
onOpen?: (record: import("./matcher.js").HelpRecord) => void;
|
|
24
|
+
onClose?: () => void;
|
|
25
|
+
silent?: boolean;
|
|
26
|
+
attribute?: string;
|
|
27
|
+
render?: (record: import("./matcher.js").HelpRecord) => (Node | null | undefined);
|
|
28
|
+
markerLabel?: string;
|
|
29
|
+
markerPlacement?: import("@floating-ui/dom").Placement;
|
|
30
|
+
popupPlacement?: import("@floating-ui/dom").Placement;
|
|
31
|
+
nonce?: string;
|
|
32
|
+
}): {
|
|
33
|
+
enable: () => void;
|
|
34
|
+
disable: () => void;
|
|
35
|
+
toggle: () => void;
|
|
36
|
+
isActive(): boolean;
|
|
37
|
+
open: (key: any) => void;
|
|
38
|
+
close: () => void;
|
|
39
|
+
update: (newConfig: any) => void;
|
|
40
|
+
destroy(): void;
|
|
41
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "help-layer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"sideEffects": false,
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"main": "src/index.js",
|
|
10
|
+
"unpkg": "dist/help-layer.iife.js",
|
|
11
|
+
"types": "dist/types/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/types/index.d.ts",
|
|
15
|
+
"import": "./src/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./dist/*": "./dist/*"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"src",
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"directories": {
|
|
24
|
+
"doc": "doc"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
|
28
|
+
"test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
|
29
|
+
"test:e2e": "playwright test",
|
|
30
|
+
"test:e2e:ui": "playwright test --ui",
|
|
31
|
+
"lint": "eslint src tests",
|
|
32
|
+
"lint:fix": "eslint src tests --fix",
|
|
33
|
+
"typecheck": "tsc -p jsconfig.json",
|
|
34
|
+
"check": "npm run lint && npm run typecheck && npm test",
|
|
35
|
+
"build:bundle": "node scripts/build.js",
|
|
36
|
+
"build:types": "tsc -p jsconfig.json --declaration --emitDeclarationOnly --noEmit false --rootDir src --outDir dist/types",
|
|
37
|
+
"build": "npm run build:bundle && npm run build:types",
|
|
38
|
+
"prepublishOnly": "npm run build",
|
|
39
|
+
"demo": "node scripts/serve.js"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@playwright/test": "^1.61.0",
|
|
43
|
+
"esbuild": "^0.28.1",
|
|
44
|
+
"eslint": "^9.39.2",
|
|
45
|
+
"eslint-plugin-import": "^2.32.0",
|
|
46
|
+
"eslint-plugin-n": "^17.24.0",
|
|
47
|
+
"eslint-plugin-promise": "^7.2.1",
|
|
48
|
+
"jest": "^29.7.0",
|
|
49
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
50
|
+
"react": "^19.2.7",
|
|
51
|
+
"react-dom": "^19.2.7",
|
|
52
|
+
"typescript": "^6.0.3",
|
|
53
|
+
"vue": "^3.5.38"
|
|
54
|
+
},
|
|
55
|
+
"keywords": [
|
|
56
|
+
"help-layer",
|
|
57
|
+
"tooltip",
|
|
58
|
+
"onboarding",
|
|
59
|
+
"walkthrough",
|
|
60
|
+
"guide",
|
|
61
|
+
"annotation",
|
|
62
|
+
"accessibility",
|
|
63
|
+
"a11y",
|
|
64
|
+
"shadow-dom",
|
|
65
|
+
"framework-agnostic"
|
|
66
|
+
],
|
|
67
|
+
"author": "Y1-Effy <ssoruto@yahoo.co.jp>",
|
|
68
|
+
"license": "ISC",
|
|
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
|
+
"repository": {
|
|
71
|
+
"type": "git",
|
|
72
|
+
"url": "git+https://github.com/Y1-Effy/HelpLayer.git"
|
|
73
|
+
},
|
|
74
|
+
"homepage": "https://github.com/Y1-Effy/HelpLayer#readme",
|
|
75
|
+
"bugs": {
|
|
76
|
+
"url": "https://github.com/Y1-Effy/HelpLayer/issues"
|
|
77
|
+
},
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"@floating-ui/dom": "^1.7.6"
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transparent interaction-blocking layer.
|
|
3
|
+
*
|
|
4
|
+
* Without touching the original app's event listeners at all, it keeps interactions from getting through. How it works:
|
|
5
|
+
*
|
|
6
|
+
* 1. Let the toggle show through via a clip-path "hole":
|
|
7
|
+
* Set a clip-path polygon on the full-screen layer made of the outer rectangle + the toggle
|
|
8
|
+
* rectangle (the hole). Inside the hole the layer isn't painted and hit-testing passes through,
|
|
9
|
+
* so the toggle can be clicked natively without touching z-index at all. Unlike approaches that
|
|
10
|
+
* shuffle z-index, this doesn't break depending on ancestor stacking contexts.
|
|
11
|
+
* The hole is updated via autoUpdate to follow the toggle's scroll/resize.
|
|
12
|
+
*
|
|
13
|
+
* 2. Focus containment:
|
|
14
|
+
* On ON, blur activeElement, and via focusin (capture) detect focus moving to anything other
|
|
15
|
+
* than the library UI and pull it back to the toggle. Host listeners are not detached.
|
|
16
|
+
*
|
|
17
|
+
* 3. Key-input suppression:
|
|
18
|
+
* Capture keydown/keyup in document's capture phase; for anything outside the library UI,
|
|
19
|
+
* stopPropagation+preventDefault. Escape has dedicated handling (close popup / exit mode).
|
|
20
|
+
* Inside the library UI (marker/popup/toggle), normal interactions like Tab are allowed.
|
|
21
|
+
*/
|
|
22
|
+
import { createBlockingLayer } from './dom-builder.js';
|
|
23
|
+
import { watchReference } from './floating.js';
|
|
24
|
+
|
|
25
|
+
function buildClipPath(rect) {
|
|
26
|
+
const x1 = rect.left;
|
|
27
|
+
const y1 = rect.top;
|
|
28
|
+
const x2 = rect.right;
|
|
29
|
+
const y2 = rect.bottom;
|
|
30
|
+
// A single stroke: outer ring (clockwise) -> bridge -> toggle rectangle (counter-clockwise).
|
|
31
|
+
// Under the nonzero winding rule, making the inner ring wind opposite to the outer one punches a hole.
|
|
32
|
+
return `polygon(
|
|
33
|
+
0px 0px, 100% 0px, 100% 100%, 0px 100%, 0px 0px,
|
|
34
|
+
${x1}px ${y1}px, ${x1}px ${y2}px, ${x2}px ${y2}px, ${x2}px ${y1}px, ${x1}px ${y1}px
|
|
35
|
+
)`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function activateBlockingLayer(state, {
|
|
39
|
+
toggleEl,
|
|
40
|
+
onBackgroundClick,
|
|
41
|
+
isLibraryElement,
|
|
42
|
+
onEscape,
|
|
43
|
+
}) {
|
|
44
|
+
const layer = createBlockingLayer();
|
|
45
|
+
document.body.appendChild(layer);
|
|
46
|
+
state.track(() => layer.remove());
|
|
47
|
+
|
|
48
|
+
// --- 1. Keep the clip-path hole following the toggle position ---
|
|
49
|
+
// If there's no toggle element (programmatic control only), keeping the whole surface blocked with no hole is correct.
|
|
50
|
+
if (toggleEl) {
|
|
51
|
+
const updateClip = () => {
|
|
52
|
+
layer.style.clipPath = buildClipPath(toggleEl.getBoundingClientRect());
|
|
53
|
+
};
|
|
54
|
+
const cleanupClipWatch = watchReference(toggleEl, layer, updateClip);
|
|
55
|
+
state.track(cleanupClipWatch);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Clicking the background (the layer itself) closes the popup
|
|
59
|
+
if (onBackgroundClick) {
|
|
60
|
+
layer.addEventListener('click', onBackgroundClick);
|
|
61
|
+
state.track(() => layer.removeEventListener('click', onBackgroundClick));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- 2. Focus containment ---
|
|
65
|
+
// Turning ON happens via a click on the toggle, so don't blur if the active element is the toggle
|
|
66
|
+
// itself (blurring would leave focus floating). For any other host element, make it let go.
|
|
67
|
+
const activeEl = document.activeElement;
|
|
68
|
+
if (
|
|
69
|
+
activeEl instanceof HTMLElement &&
|
|
70
|
+
activeEl !== document.body &&
|
|
71
|
+
activeEl !== toggleEl
|
|
72
|
+
) {
|
|
73
|
+
activeEl.blur();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const handleFocusIn = (event) => {
|
|
77
|
+
if (isLibraryElement(event.target)) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// If focus tries to move to a host element, take it back.
|
|
81
|
+
// To the toggle if there is one; otherwise blur that element so it isn't handed to the host.
|
|
82
|
+
event.stopPropagation();
|
|
83
|
+
if (toggleEl) {
|
|
84
|
+
toggleEl.focus({ preventScroll: true });
|
|
85
|
+
} else if (event.target instanceof HTMLElement) {
|
|
86
|
+
event.target.blur();
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
document.addEventListener('focusin', handleFocusIn, true);
|
|
90
|
+
state.track(() => document.removeEventListener('focusin', handleFocusIn, true));
|
|
91
|
+
|
|
92
|
+
// --- 3. Key-input suppression + Escape ---
|
|
93
|
+
// Shared logic that doesn't pass key input destined outside the library UI through to the host.
|
|
94
|
+
const blockNonLibrary = (event) => {
|
|
95
|
+
if (isLibraryElement(event.target)) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
event.stopPropagation();
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
};
|
|
101
|
+
// keyup / keypress: don't leak to the host, Escape included (Escape's real handling is on the keydown side).
|
|
102
|
+
const blockKey = (event) => {
|
|
103
|
+
if (event.key === 'Escape') {
|
|
104
|
+
event.stopPropagation();
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
blockNonLibrary(event);
|
|
109
|
+
};
|
|
110
|
+
const handleKeydown = (event) => {
|
|
111
|
+
if (event.key === 'Escape') {
|
|
112
|
+
event.stopPropagation();
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
if (onEscape) {
|
|
115
|
+
onEscape();
|
|
116
|
+
}
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
blockNonLibrary(event);
|
|
120
|
+
};
|
|
121
|
+
document.addEventListener('keydown', handleKeydown, true);
|
|
122
|
+
document.addEventListener('keyup', blockKey, true);
|
|
123
|
+
document.addEventListener('keypress', blockKey, true);
|
|
124
|
+
state.track(() => {
|
|
125
|
+
document.removeEventListener('keydown', handleKeydown, true);
|
|
126
|
+
document.removeEventListener('keyup', blockKey, true);
|
|
127
|
+
document.removeEventListener('keypress', blockKey, true);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return layer;
|
|
131
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation and normalization of the helpConfig object. A pure function that does no DOM work.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} HelpEntry
|
|
7
|
+
* @property {string} title heading shown on the marker / popup (non-empty)
|
|
8
|
+
* @property {string} text description body (non-empty)
|
|
9
|
+
* @property {{ top: number, left: number }} [position] if given, becomes a free placement not tied to an element
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The helpConfig itself. For element-bound entries the key is the `data-help-id` value;
|
|
14
|
+
* for free placement it is any identifier.
|
|
15
|
+
* @typedef {Object<string, HelpEntry>} HelpConfig
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
function isPlainObject(value) {
|
|
19
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isValidPosition(position) {
|
|
23
|
+
return (
|
|
24
|
+
isPlainObject(position) &&
|
|
25
|
+
typeof position.top === 'number' &&
|
|
26
|
+
typeof position.left === 'number'
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Validate the shape of helpConfig. Throws an Error on any problem (fail fast).
|
|
32
|
+
*/
|
|
33
|
+
export function validateConfig(config) {
|
|
34
|
+
if (!isPlainObject(config)) {
|
|
35
|
+
throw new Error('helpConfig must be a plain object');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const [key, entry] of Object.entries(config)) {
|
|
39
|
+
if (!isPlainObject(entry)) {
|
|
40
|
+
throw new Error(`helpConfig["${key}"] must be an object`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof entry.title !== 'string' || entry.title === '') {
|
|
43
|
+
throw new Error(`helpConfig["${key}"].title must be a non-empty string`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof entry.text !== 'string' || entry.text === '') {
|
|
46
|
+
throw new Error(`helpConfig["${key}"].text must be a non-empty string`);
|
|
47
|
+
}
|
|
48
|
+
if (entry.position !== undefined && !isValidPosition(entry.position)) {
|
|
49
|
+
throw new Error(`helpConfig["${key}"].position must be { top: number, left: number }`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert a validated helpConfig into the shared array of "help items" the rendering
|
|
56
|
+
* code works with. The target of kind:'element' is null at this point (DOM matching is
|
|
57
|
+
* left to matcher.js).
|
|
58
|
+
*/
|
|
59
|
+
export function normalizeConfig(config) {
|
|
60
|
+
return Object.entries(config).map(([key, entry]) => {
|
|
61
|
+
if (isValidPosition(entry.position)) {
|
|
62
|
+
return {
|
|
63
|
+
key,
|
|
64
|
+
title: entry.title,
|
|
65
|
+
text: entry.text,
|
|
66
|
+
kind: 'free',
|
|
67
|
+
target: null,
|
|
68
|
+
position: { top: entry.position.top, left: entry.position.left },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
key,
|
|
74
|
+
title: entry.title,
|
|
75
|
+
text: entry.text,
|
|
76
|
+
kind: 'element',
|
|
77
|
+
target: null,
|
|
78
|
+
position: null,
|
|
79
|
+
};
|
|
80
|
+
});
|
|
81
|
+
}
|