help-layer 1.1.0 → 1.3.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/README.ja.md +101 -10
- package/README.md +106 -10
- package/dist/help-layer.esm.js +8 -8
- package/dist/help-layer.esm.js.map +4 -4
- package/dist/help-layer.iife.js +9 -9
- package/dist/help-layer.iife.js.map +4 -4
- package/dist/types/aria-isolation.d.ts +10 -0
- package/dist/types/floating.d.ts +1 -70
- package/dist/types/floating.floatingui.d.ts +25 -0
- package/dist/types/floating.self.d.ts +26 -0
- package/dist/types/geometry.d.ts +85 -4
- package/dist/types/index.d.ts +4 -4
- package/dist/types/markers.d.ts +42 -4
- package/dist/types/overlap.d.ts +2 -2
- package/dist/types/popup.d.ts +2 -2
- package/dist/types/reference.d.ts +41 -0
- package/dist/types/toggle.d.ts +4 -4
- package/dist/types/types.d.ts +7 -0
- package/package.json +10 -13
- package/src/aria-isolation.js +67 -0
- package/src/blocking-layer.js +1 -1
- package/src/dom-builder.js +22 -4
- package/src/floating.floatingui.js +71 -0
- package/src/floating.js +8 -167
- package/src/floating.self.js +109 -0
- package/src/geometry.js +168 -4
- package/src/index.js +2 -2
- package/src/markers.js +168 -43
- package/src/overlap.js +2 -2
- package/src/popup.js +40 -7
- package/src/reference.js +74 -0
- package/src/style.js +6 -6
- package/src/toggle.js +21 -3
- package/src/types.js +17 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {object} state teardown registry
|
|
3
|
+
* @param {object} params
|
|
4
|
+
* @param {HTMLElement|null} params.toggleEl the toggle (must stay reachable), or null for programmatic-only
|
|
5
|
+
* @param {(el: Element) => boolean} params.isLibraryNode whether a body child belongs to the library UI
|
|
6
|
+
*/
|
|
7
|
+
export function isolateBackgroundFromAT(state: object, { toggleEl, isLibraryNode }: {
|
|
8
|
+
toggleEl: HTMLElement | null;
|
|
9
|
+
isLibraryNode: (el: Element) => boolean;
|
|
10
|
+
}): void;
|
package/dist/types/floating.d.ts
CHANGED
|
@@ -1,70 +1 @@
|
|
|
1
|
-
|
|
2
|
-
* Create a "virtual reference element" for free-placement items not bound to an element.
|
|
3
|
-
* When getDocRect() returns document coordinates, this converts them to viewport
|
|
4
|
-
* coordinates according to the current scroll. Because autoUpdate re-evaluates on every
|
|
5
|
-
* scroll, the element sticks to the given coordinate while scrolling along with the page.
|
|
6
|
-
* @param {() => {top:number,left:number,width?:number,height?:number}} getDocRect
|
|
7
|
-
*/
|
|
8
|
-
export function makeVirtualElement(getDocRect: () => {
|
|
9
|
-
top: number;
|
|
10
|
-
left: number;
|
|
11
|
-
width?: number;
|
|
12
|
-
height?: number;
|
|
13
|
-
}): {
|
|
14
|
-
contextElement: HTMLElement;
|
|
15
|
-
getBoundingClientRect(): {
|
|
16
|
-
x: number;
|
|
17
|
-
y: number;
|
|
18
|
-
left: number;
|
|
19
|
-
top: number;
|
|
20
|
-
right: number;
|
|
21
|
-
bottom: number;
|
|
22
|
-
width: number;
|
|
23
|
-
height: number;
|
|
24
|
-
};
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* Whether the reference lives in a `position: fixed` subtree. Such a reference stays put in the
|
|
28
|
-
* viewport while the page scrolls, so an absolutely-positioned floating element (which scrolls with
|
|
29
|
-
* the document) would have to be re-corrected every frame and visibly jitters. For these we switch the
|
|
30
|
-
* floating element to Floating UI's `fixed` strategy (and position:fixed) so both live in the same
|
|
31
|
-
* viewport space and stay glued without per-frame correction.
|
|
32
|
-
*
|
|
33
|
-
* Virtual elements (free placements) aren't in the DOM and already track scroll via their getRect, so
|
|
34
|
-
* they report false. Walks across shadow boundaries via the host so Shadow DOM targets are handled too.
|
|
35
|
-
* @param {Element|object} reference
|
|
36
|
-
*/
|
|
37
|
-
export function isFixedReference(reference: Element | object): boolean;
|
|
38
|
-
/**
|
|
39
|
-
* Overlap the marker onto a corner of the target (element or virtual element), stick it there, and keep it following.
|
|
40
|
-
* @param {Element|object} reference
|
|
41
|
-
* @param {HTMLElement} markerEl
|
|
42
|
-
* @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
|
|
43
|
-
* @param {import('@floating-ui/dom').Placement} [placement] corner to overlap (top-end/top-start/bottom-end/bottom-start). Default 'top-end'
|
|
44
|
-
* @returns {() => void} cleanup
|
|
45
|
-
*/
|
|
46
|
-
export function anchorMarker(reference: Element | object, markerEl: HTMLElement, onPlaced?: () => void, placement?: import("@floating-ui/dom").Placement): () => void;
|
|
47
|
-
/**
|
|
48
|
-
* Place the popup below the target, and at screen edges use flip (flip to the opposite side) /
|
|
49
|
-
* shift (nudge) to avoid clipping. Only follows while visible.
|
|
50
|
-
* @param {Element|object} reference
|
|
51
|
-
* @param {HTMLElement} popupEl
|
|
52
|
-
* @param {import('@floating-ui/dom').Placement} [placement] initial placement (Floating UI placement). Default 'bottom-start'
|
|
53
|
-
* @returns {{ update: () => void, cleanup: () => void }}
|
|
54
|
-
* calling update repositions immediately (used for reference-side transform moves that autoUpdate doesn't pick up, etc.).
|
|
55
|
-
*/
|
|
56
|
-
export function anchorPopup(reference: Element | object, popupEl: HTMLElement, placement?: import("@floating-ui/dom").Placement): {
|
|
57
|
-
update: () => void;
|
|
58
|
-
cleanup: () => void;
|
|
59
|
-
};
|
|
60
|
-
/**
|
|
61
|
-
* Watch a reference element's position/size changes and call onUpdate on every change.
|
|
62
|
-
* (Used for non-placement purposes, e.g. keeping the blocking layer's clip-path hole following the toggle position.)
|
|
63
|
-
* autoUpdate requires a floating element, so floatingEl is just passed as a dummy that
|
|
64
|
-
* onUpdate doesn't actually position.
|
|
65
|
-
* @param {Element} referenceEl
|
|
66
|
-
* @param {HTMLElement} floatingEl
|
|
67
|
-
* @param {() => void} onUpdate
|
|
68
|
-
* @returns {() => void} cleanup
|
|
69
|
-
*/
|
|
70
|
-
export function watchReference(referenceEl: Element, floatingEl: HTMLElement, onUpdate: () => void): () => void;
|
|
1
|
+
export * from "./floating.self.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Place the popup below the target, and at screen edges use flip / shift to avoid clipping.
|
|
3
|
+
* @param {Element|object} reference
|
|
4
|
+
* @param {HTMLElement} popupEl
|
|
5
|
+
* @param {import('./types.js').Placement} [placement] initial placement. Default 'bottom-start'
|
|
6
|
+
* @returns {{ update: () => void, cleanup: () => void }}
|
|
7
|
+
*/
|
|
8
|
+
export function anchorPopup(reference: Element | object, popupEl: HTMLElement, placement?: import("./types.js").Placement): {
|
|
9
|
+
update: () => void;
|
|
10
|
+
cleanup: () => void;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Watch a reference element's position/size changes and call onUpdate on every change.
|
|
14
|
+
* (Used to keep the blocking layer's clip-path hole following the toggle.) autoUpdate requires a
|
|
15
|
+
* floating element, so floatingEl is passed as a dummy that onUpdate doesn't actually position.
|
|
16
|
+
* @param {Element} referenceEl
|
|
17
|
+
* @param {HTMLElement} floatingEl
|
|
18
|
+
* @param {() => void} onUpdate
|
|
19
|
+
* @returns {() => void} cleanup
|
|
20
|
+
*/
|
|
21
|
+
export function watchReference(referenceEl: Element, floatingEl: HTMLElement, onUpdate: () => void): () => void;
|
|
22
|
+
import { isFixedReference } from './reference.js';
|
|
23
|
+
import { isReferenceHidden } from './reference.js';
|
|
24
|
+
import { makeVirtualElement } from './reference.js';
|
|
25
|
+
export { isFixedReference, isReferenceHidden, makeVirtualElement };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Place the popup on a side of the target with a gap, flipping/shifting at screen edges to stay
|
|
3
|
+
* visible, and keep it following while open.
|
|
4
|
+
* @param {Element|object} reference
|
|
5
|
+
* @param {HTMLElement} popupEl
|
|
6
|
+
* @param {import('./types.js').Placement} [placement] initial placement. Default 'bottom-start'
|
|
7
|
+
* @returns {{ update: () => void, cleanup: () => void }}
|
|
8
|
+
*/
|
|
9
|
+
export function anchorPopup(reference: Element | object, popupEl: HTMLElement, placement?: import("./types.js").Placement): {
|
|
10
|
+
update: () => void;
|
|
11
|
+
cleanup: () => void;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Watch a reference element's position/size changes and call onUpdate on every change.
|
|
15
|
+
* (Used to keep the blocking layer's clip-path hole following the toggle.) floatingEl is accepted for
|
|
16
|
+
* signature parity with the Floating UI backend but isn't used here.
|
|
17
|
+
* @param {Element} referenceEl
|
|
18
|
+
* @param {HTMLElement} _floatingEl unused (kept for backend parity)
|
|
19
|
+
* @param {() => void} onUpdate
|
|
20
|
+
* @returns {() => void} cleanup
|
|
21
|
+
*/
|
|
22
|
+
export function watchReference(referenceEl: Element, _floatingEl: HTMLElement, onUpdate: () => void): () => void;
|
|
23
|
+
import { isFixedReference } from './reference.js';
|
|
24
|
+
import { isReferenceHidden } from './reference.js';
|
|
25
|
+
import { makeVirtualElement } from './reference.js';
|
|
26
|
+
export { isFixedReference, isReferenceHidden, makeVirtualElement };
|
package/dist/types/geometry.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pure geometry calculations. Takes no DOM elements, only numbers already read off.
|
|
3
3
|
*
|
|
4
|
-
* Clamping things that overflow the viewport is handled by
|
|
5
|
-
*
|
|
4
|
+
* Clamping things that overflow the viewport is handled by computePopupPosition's shift step
|
|
5
|
+
* below. toDocumentPosition is used for the virtual-element math of free placement, etc.
|
|
6
6
|
*/
|
|
7
7
|
/**
|
|
8
8
|
* Given getBoundingClientRect() values (viewport-relative) and the scroll offset,
|
|
@@ -14,8 +14,8 @@ export function toDocumentPosition(rect: any, scroll: any): {
|
|
|
14
14
|
};
|
|
15
15
|
/**
|
|
16
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
|
|
18
|
-
*
|
|
17
|
+
* the current scroll offset. This is what the getBoundingClientRect of a virtual
|
|
18
|
+
* reference element (a free-placement marker) returns.
|
|
19
19
|
* @param {{top:number,left:number,width?:number,height?:number}} docRect
|
|
20
20
|
* @param {{x:number,y:number}} scroll
|
|
21
21
|
*/
|
|
@@ -37,3 +37,84 @@ export function docRectToViewportRect(docRect: {
|
|
|
37
37
|
width: number;
|
|
38
38
|
height: number;
|
|
39
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Compute a marker's top-left in viewport coordinates so it overlaps a corner of the reference rect,
|
|
42
|
+
* replicating what Floating UI's computePosition(placement) + offset(markerOffset) produced before.
|
|
43
|
+
*
|
|
44
|
+
* placement is "<side>" or "<side>-<align>" where side is top/bottom/left/right and align is
|
|
45
|
+
* start/end (omitted = centered). The marker is a square of the given size. mainAxis bites INSET
|
|
46
|
+
* inward (overlapping the target edge); crossAxis nudges INSET inward from the aligned edge.
|
|
47
|
+
* @param {{top:number,left:number,width:number,height:number}} refRect viewport rect of the target
|
|
48
|
+
* @param {number} size marker width/height (square)
|
|
49
|
+
* @param {string} placement a placement string (see Placement in types.js; default 'top-end')
|
|
50
|
+
* @returns {{left:number, top:number}} viewport coordinates of the marker's top-left corner
|
|
51
|
+
*/
|
|
52
|
+
export function markerViewportTopLeft(refRect: {
|
|
53
|
+
top: number;
|
|
54
|
+
left: number;
|
|
55
|
+
width: number;
|
|
56
|
+
height: number;
|
|
57
|
+
}, size: number, placement?: string): {
|
|
58
|
+
left: number;
|
|
59
|
+
top: number;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Convert a viewport coordinate into the left/top to set on a `position:absolute` element whose
|
|
63
|
+
* offsetParent is `document.body`. Both the marker's reference rect and the body rect come from
|
|
64
|
+
* getBoundingClientRect (viewport space), so their difference is scroll-invariant — which is exactly
|
|
65
|
+
* why the marker stays anchored to its target as the page scrolls. clientLeft/clientTop subtract the
|
|
66
|
+
* body's border so the offset is measured from the body's padding-box origin (the absolute origin).
|
|
67
|
+
* For `position:fixed` markers no conversion is needed (viewport coordinates are used as-is).
|
|
68
|
+
* @param {number} vx viewport x
|
|
69
|
+
* @param {number} vy viewport y
|
|
70
|
+
* @param {{left:number, top:number}} bodyRect document.body's getBoundingClientRect
|
|
71
|
+
* @param {number} clientLeft document.body.clientLeft (left border width)
|
|
72
|
+
* @param {number} clientTop document.body.clientTop (top border width)
|
|
73
|
+
* @returns {{left:number, top:number}}
|
|
74
|
+
*/
|
|
75
|
+
export function viewportToAbsolute(vx: number, vy: number, bodyRect: {
|
|
76
|
+
left: number;
|
|
77
|
+
top: number;
|
|
78
|
+
}, clientLeft: number, clientTop: number): {
|
|
79
|
+
left: number;
|
|
80
|
+
top: number;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Compute the popup's top-left in viewport coordinates, replicating the small subset of Floating UI
|
|
84
|
+
* we relied on: place on a side of the reference with a gap (offset), flip to the opposite side when
|
|
85
|
+
* the preferred side doesn't fit (main axis), and shift along the cross axis to keep it inside the
|
|
86
|
+
* viewport (with padding). This covers the popup's case — a single element over document.body whose
|
|
87
|
+
* clipping boundary is the viewport — and is intentionally simpler than Floating UI (no nested
|
|
88
|
+
* clipping ancestors / transforms / RTL).
|
|
89
|
+
*
|
|
90
|
+
* placement is "<side>" or "<side>-<align>" (side: top/bottom/left/right; align: start/end, omitted = center).
|
|
91
|
+
* @param {{top:number,left:number,width:number,height:number}} refRect viewport rect of the reference
|
|
92
|
+
* @param {{width:number,height:number}} popupSize the popup's measured size
|
|
93
|
+
* @param {{width:number,height:number}} viewport innerWidth/innerHeight
|
|
94
|
+
* @param {object} [opts]
|
|
95
|
+
* @param {string} [opts.placement] default 'bottom-start'
|
|
96
|
+
* @param {number} [opts.offset] gap between reference and popup along the main axis (default 8)
|
|
97
|
+
* @param {number} [opts.padding] minimum gap kept from the viewport edges (default 8)
|
|
98
|
+
* @returns {{left:number, top:number, placement:string}} resolved viewport coords + the side actually used
|
|
99
|
+
*/
|
|
100
|
+
export function computePopupPosition(refRect: {
|
|
101
|
+
top: number;
|
|
102
|
+
left: number;
|
|
103
|
+
width: number;
|
|
104
|
+
height: number;
|
|
105
|
+
}, popupSize: {
|
|
106
|
+
width: number;
|
|
107
|
+
height: number;
|
|
108
|
+
}, viewport: {
|
|
109
|
+
width: number;
|
|
110
|
+
height: number;
|
|
111
|
+
}, opts?: {
|
|
112
|
+
placement?: string;
|
|
113
|
+
offset?: number;
|
|
114
|
+
padding?: number;
|
|
115
|
+
}): {
|
|
116
|
+
left: number;
|
|
117
|
+
top: number;
|
|
118
|
+
placement: string;
|
|
119
|
+
};
|
|
120
|
+
export const MARKER_INSET: 12;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
* Return a Node to display it; if nothing is returned, fall back to safe text display (the title is always record.title).
|
|
19
19
|
* ⚠️ The return value is inserted as-is without sanitization. If it contains untrusted data, neutralize it on the caller side (XSS prevention)
|
|
20
20
|
* @param {string} [options.markerLabel] - character shown on the marker (default '?')
|
|
21
|
-
* @param {import('
|
|
22
|
-
* @param {import('
|
|
21
|
+
* @param {import('./types.js').Placement} [options.markerPlacement] - corner to overlap the marker onto (default 'top-end')
|
|
22
|
+
* @param {import('./types.js').Placement} [options.popupPlacement] - initial popup placement (default 'bottom-start')
|
|
23
23
|
* @param {string} [options.nonce] - nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
|
|
24
24
|
* @returns {{
|
|
25
25
|
* enable(): void,
|
|
@@ -44,8 +44,8 @@ export function initHelpLayer(options: {
|
|
|
44
44
|
attribute?: string;
|
|
45
45
|
render?: (record: import("./matcher.js").HelpRecord) => (Node | null | undefined);
|
|
46
46
|
markerLabel?: string;
|
|
47
|
-
markerPlacement?: import("
|
|
48
|
-
popupPlacement?: import("
|
|
47
|
+
markerPlacement?: import("./types.js").Placement;
|
|
48
|
+
popupPlacement?: import("./types.js").Placement;
|
|
49
49
|
nonce?: string;
|
|
50
50
|
}): {
|
|
51
51
|
enable(): void;
|
package/dist/types/markers.d.ts
CHANGED
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
* @param {object} state teardown registry
|
|
3
3
|
* @param {object} options
|
|
4
4
|
* @param {(record: import('./matcher.js').HelpRecord, markerEl: HTMLElement) => void} options.onMarkerClick
|
|
5
|
-
* @param {() => void} [options.onOverlapResolved]
|
|
5
|
+
* @param {() => void} [options.onOverlapResolved] called once per frame in which any marker actually moved
|
|
6
|
+
* @param {(record: import('./matcher.js').HelpRecord) => void} [options.onMarkerHidden] called when a
|
|
7
|
+
* marker's target transitions to hidden (e.g. display:none) — lets the caller close a popup open on it
|
|
6
8
|
* @param {string} [options.markerLabel] character shown on the marker (default '?')
|
|
7
|
-
* @param {import('
|
|
9
|
+
* @param {import('./types.js').Placement} [options.markerPlacement] corner to overlap (default 'top-end')
|
|
8
10
|
*/
|
|
9
|
-
export function createMarkerManager(state: object, { onMarkerClick, onOverlapResolved, markerLabel, markerPlacement, }: {
|
|
11
|
+
export function createMarkerManager(state: object, { onMarkerClick, onOverlapResolved, onMarkerHidden, markerLabel, markerPlacement, }: {
|
|
10
12
|
onMarkerClick: (record: import("./matcher.js").HelpRecord, markerEl: HTMLElement) => void;
|
|
11
13
|
onOverlapResolved?: () => void;
|
|
14
|
+
onMarkerHidden?: (record: import("./matcher.js").HelpRecord) => void;
|
|
12
15
|
markerLabel?: string;
|
|
13
|
-
markerPlacement?: import("
|
|
16
|
+
markerPlacement?: import("./types.js").Placement;
|
|
14
17
|
}): {
|
|
15
18
|
mount: (record: import("./matcher.js").HelpRecord) => void;
|
|
16
19
|
unmount: (id: any) => void;
|
|
@@ -19,6 +22,41 @@ export function createMarkerManager(state: object, { onMarkerClick, onOverlapRes
|
|
|
19
22
|
findByKey(key: any): {
|
|
20
23
|
record: import("./matcher.js").HelpRecord;
|
|
21
24
|
el: HTMLElement;
|
|
25
|
+
/**
|
|
26
|
+
* positioning reference (element or virtual element)
|
|
27
|
+
*/
|
|
28
|
+
reference: Element | object;
|
|
29
|
+
/**
|
|
30
|
+
* positioning strategy chosen from the reference
|
|
31
|
+
*/
|
|
32
|
+
strategy: "fixed" | "absolute";
|
|
33
|
+
/**
|
|
34
|
+
* corner to overlap onto
|
|
35
|
+
*/
|
|
36
|
+
placement: import("./types.js").Placement;
|
|
22
37
|
cleanup: () => void;
|
|
38
|
+
/**
|
|
39
|
+
* whether the target is currently reported hidden (edge tracking for onMarkerHidden)
|
|
40
|
+
*/
|
|
41
|
+
hidden: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* the reference rect read during the current frame's read phase
|
|
44
|
+
*/
|
|
45
|
+
refRect?: DOMRect | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* previous frame's pre-overlap position (element space) — movement detection
|
|
48
|
+
*/
|
|
49
|
+
lastBaseEl: {
|
|
50
|
+
left: number;
|
|
51
|
+
top: number;
|
|
52
|
+
} | null;
|
|
53
|
+
/**
|
|
54
|
+
* last written left (px), to skip redundant DOM writes
|
|
55
|
+
*/
|
|
56
|
+
lastLeft: number | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* last written top (px)
|
|
59
|
+
*/
|
|
60
|
+
lastTop: number | undefined;
|
|
23
61
|
};
|
|
24
62
|
};
|
package/dist/types/overlap.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Overlap avoidance between markers (pure function).
|
|
3
3
|
*
|
|
4
|
-
* Takes an array of each marker's "base position" (the center coordinate
|
|
5
|
-
* decided on) and returns an array of extra offsets that push overlapping ones apart.
|
|
4
|
+
* Takes an array of each marker's "base position" (the center coordinate the positioning
|
|
5
|
+
* pass decided on) and returns an array of extra offsets that push overlapping ones apart.
|
|
6
6
|
* Touches no DOM.
|
|
7
7
|
*
|
|
8
8
|
* The algorithm is a simple iterative push-out (a lightweight force-based separation):
|
package/dist/types/popup.d.ts
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* Escape hatch to render the body area with your own DOM node. Return a Node to display it;
|
|
7
7
|
* if nothing is returned, fall back to safe text rendering (textContent). The title is always record.title.
|
|
8
8
|
* Note: the return value is appendChild'd as-is without sanitization, so untrusted data must be neutralized by the caller.
|
|
9
|
-
* @param {import('
|
|
9
|
+
* @param {import('./types.js').Placement} [options.popupPlacement] initial placement (default 'bottom-start')
|
|
10
10
|
*/
|
|
11
11
|
export function createPopupController(state: object, { onClose, render, popupPlacement }?: {
|
|
12
12
|
onClose?: () => void;
|
|
13
13
|
render?: (record: import("./matcher.js").HelpRecord) => (Node | null | undefined);
|
|
14
|
-
popupPlacement?: import("
|
|
14
|
+
popupPlacement?: import("./types.js").Placement;
|
|
15
15
|
}): {
|
|
16
16
|
root: HTMLDivElement;
|
|
17
17
|
isOpen(id: any): boolean;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create a "virtual reference element" for free-placement items not bound to an element.
|
|
3
|
+
* getDocRect() returns document coordinates; this converts them to viewport coordinates for the
|
|
4
|
+
* current scroll, so the element tracks the page as it scrolls (it's re-read every frame).
|
|
5
|
+
* @param {() => {top:number,left:number,width?:number,height?:number}} getDocRect
|
|
6
|
+
*/
|
|
7
|
+
export function makeVirtualElement(getDocRect: () => {
|
|
8
|
+
top: number;
|
|
9
|
+
left: number;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
}): {
|
|
13
|
+
contextElement: HTMLElement;
|
|
14
|
+
getBoundingClientRect(): {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
left: number;
|
|
18
|
+
top: number;
|
|
19
|
+
right: number;
|
|
20
|
+
bottom: number;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Whether the reference lives in a `position: fixed` subtree. Such a reference stays put in the
|
|
27
|
+
* viewport while the page scrolls, so an absolutely-positioned floating element (which scrolls with
|
|
28
|
+
* the document) would drift; for these we switch the floating element to a fixed strategy so both
|
|
29
|
+
* live in the same viewport space and stay glued without per-frame correction.
|
|
30
|
+
*
|
|
31
|
+
* Virtual elements (free placements) aren't in the DOM and already track scroll via their getRect, so
|
|
32
|
+
* they report false. Walks across shadow boundaries via the host so Shadow DOM targets are handled too.
|
|
33
|
+
* @param {Element|object} reference
|
|
34
|
+
*/
|
|
35
|
+
export function isFixedReference(reference: Element | object): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Whether a reference element is currently not rendered (hidden). Free placements use a virtual
|
|
38
|
+
* element with no host node, so they are never "hidden" (return false).
|
|
39
|
+
* @param {Element|object} reference
|
|
40
|
+
*/
|
|
41
|
+
export function isReferenceHidden(reference: Element | object): boolean;
|
package/dist/types/toggle.d.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* @param {(record: import('./matcher.js').HelpRecord) => (Node|null|undefined)} [options.render] render the popup body with your own Node
|
|
12
12
|
* (the return value is inserted as-is without sanitization, so untrusted data must be neutralized by the caller)
|
|
13
13
|
* @param {string} [options.markerLabel] character shown on the marker (default '?')
|
|
14
|
-
* @param {import('
|
|
15
|
-
* @param {import('
|
|
14
|
+
* @param {import('./types.js').Placement} [options.markerPlacement] corner to overlap the marker onto (default 'top-end')
|
|
15
|
+
* @param {import('./types.js').Placement} [options.popupPlacement] initial popup placement (default 'bottom-start')
|
|
16
16
|
* @param {string} [options.nonce] nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
|
|
17
17
|
*/
|
|
18
18
|
export function createToggleController(options: {
|
|
@@ -26,8 +26,8 @@ export function createToggleController(options: {
|
|
|
26
26
|
attribute?: string;
|
|
27
27
|
render?: (record: import("./matcher.js").HelpRecord) => (Node | null | undefined);
|
|
28
28
|
markerLabel?: string;
|
|
29
|
-
markerPlacement?: import("
|
|
30
|
-
popupPlacement?: import("
|
|
29
|
+
markerPlacement?: import("./types.js").Placement;
|
|
30
|
+
popupPlacement?: import("./types.js").Placement;
|
|
31
31
|
nonce?: string;
|
|
32
32
|
}): {
|
|
33
33
|
enable: () => void;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared type definitions (JSDoc only — no runtime code).
|
|
3
|
+
*
|
|
4
|
+
* `Placement` mirrors the placement strings Floating UI accepts, defined locally so the library (and
|
|
5
|
+
* its generated .d.ts) carry no dependency on
|
|
6
|
+
*/
|
|
7
|
+
export type Placement = ("top" | "top-start" | "top-end" | "right" | "right-start" | "right-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "left-start" | "left-end");
|
package/package.json
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "help-layer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": ">=18"
|
|
8
8
|
},
|
|
9
|
-
"main": "
|
|
9
|
+
"main": "dist/help-layer.esm.js",
|
|
10
|
+
"module": "dist/help-layer.esm.js",
|
|
10
11
|
"unpkg": "dist/help-layer.iife.js",
|
|
11
12
|
"types": "dist/types/index.d.ts",
|
|
12
13
|
"exports": {
|
|
13
14
|
".": {
|
|
14
15
|
"types": "./dist/types/index.d.ts",
|
|
15
|
-
"import": "./
|
|
16
|
+
"import": "./dist/help-layer.esm.js"
|
|
16
17
|
},
|
|
17
18
|
"./dist/*": "./dist/*"
|
|
18
19
|
},
|
|
@@ -38,19 +39,18 @@
|
|
|
38
39
|
"build:types": "tsc -p jsconfig.json --declaration --emitDeclarationOnly --noEmit false --rootDir src --outDir dist/types",
|
|
39
40
|
"build": "npm run build:bundle && npm run build:types",
|
|
40
41
|
"record:gif": "npm run build:demos && node scripts/record-demo.js",
|
|
41
|
-
"
|
|
42
|
+
"prepack": "npm run build",
|
|
42
43
|
"demo": "npm run build:demos && node scripts/serve.js"
|
|
43
44
|
},
|
|
44
45
|
"devDependencies": {
|
|
46
|
+
"@floating-ui/dom": "^1.7.6",
|
|
45
47
|
"@playwright/test": "^1.61.0",
|
|
46
48
|
"esbuild": "^0.28.1",
|
|
47
|
-
"eslint": "^
|
|
48
|
-
"eslint-plugin-import": "^
|
|
49
|
-
"eslint-plugin-n": "^17.24.0",
|
|
50
|
-
"eslint-plugin-promise": "^7.2.1",
|
|
49
|
+
"eslint": "^10.5.0",
|
|
50
|
+
"eslint-plugin-import-x": "^4.17.0",
|
|
51
51
|
"gifenc": "^1.0.3",
|
|
52
|
-
"jest": "^
|
|
53
|
-
"jest-environment-jsdom": "^
|
|
52
|
+
"jest": "^30.4.2",
|
|
53
|
+
"jest-environment-jsdom": "^30.4.1",
|
|
54
54
|
"pngjs": "^7.0.0",
|
|
55
55
|
"react": "^19.2.7",
|
|
56
56
|
"react-dom": "^19.2.7",
|
|
@@ -79,8 +79,5 @@
|
|
|
79
79
|
"homepage": "https://github.com/Y1-Effy/HelpLayer#readme",
|
|
80
80
|
"bugs": {
|
|
81
81
|
"url": "https://github.com/Y1-Effy/HelpLayer/issues"
|
|
82
|
-
},
|
|
83
|
-
"dependencies": {
|
|
84
|
-
"@floating-ui/dom": "^1.7.6"
|
|
85
82
|
}
|
|
86
83
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic background blocking for assistive technology (AT).
|
|
3
|
+
*
|
|
4
|
+
* The clip-path layer / focus containment / key suppression block pointer, physical focus, and
|
|
5
|
+
* physical keys, but a screen reader's virtual cursor (browse mode) reads and can activate background
|
|
6
|
+
* content regardless of focus or hit-testing. So while ON we also remove the host from the
|
|
7
|
+
* accessibility tree with `inert` (which both excludes from the a11y tree and suppresses interaction),
|
|
8
|
+
* giving AT users the same "host is inactive" guarantee.
|
|
9
|
+
*
|
|
10
|
+
* Why operate at document.body's top level: `inert` is inherited and cannot be cancelled on a
|
|
11
|
+
* descendant of an inert subtree. The toggle is host-owned and may be deeply nested, so—exactly like
|
|
12
|
+
* the clip-path "hole" that lets the toggle show through—we inert each body child that is neither a
|
|
13
|
+
* library node nor the branch containing the toggle. The toggle's own top-level branch stays
|
|
14
|
+
* reachable (a bounded leak when the toggle is nested; none when it's a direct body child).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ELEMENT_NODE = 1;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} state teardown registry
|
|
21
|
+
* @param {object} params
|
|
22
|
+
* @param {HTMLElement|null} params.toggleEl the toggle (must stay reachable), or null for programmatic-only
|
|
23
|
+
* @param {(el: Element) => boolean} params.isLibraryNode whether a body child belongs to the library UI
|
|
24
|
+
*/
|
|
25
|
+
export function isolateBackgroundFromAT(state, { toggleEl, isLibraryNode }) {
|
|
26
|
+
/** @type {Set<Element>} */
|
|
27
|
+
const isolated = new Set();
|
|
28
|
+
|
|
29
|
+
/** @param {Node} node */
|
|
30
|
+
function isolate(node) {
|
|
31
|
+
if (node.nodeType !== ELEMENT_NODE) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const el = /** @type {Element} */ (node);
|
|
35
|
+
// Skip library UI, the toggle's branch, and anything the host already made inert (so restore
|
|
36
|
+
// doesn't clobber the host's own inert state).
|
|
37
|
+
if (
|
|
38
|
+
isLibraryNode(el) ||
|
|
39
|
+
(toggleEl && (el === toggleEl || el.contains(toggleEl))) ||
|
|
40
|
+
el.hasAttribute('inert')
|
|
41
|
+
) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
el.toggleAttribute('inert', true);
|
|
45
|
+
isolated.add(el);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const child of [...document.body.children]) {
|
|
49
|
+
isolate(child);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The host may add top-level nodes while ON (SPA route changes, portals, ...). Keep them isolated
|
|
53
|
+
// too. Only direct body children matter here, so childList without subtree is enough.
|
|
54
|
+
const observer = new MutationObserver((records) => {
|
|
55
|
+
for (const record of records) {
|
|
56
|
+
record.addedNodes.forEach(isolate);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
observer.observe(document.body, { childList: true });
|
|
60
|
+
|
|
61
|
+
state.track(() => {
|
|
62
|
+
observer.disconnect();
|
|
63
|
+
// Remove inert only from the nodes we added it to (leave any host-owned inert untouched).
|
|
64
|
+
isolated.forEach((el) => el.removeAttribute('inert'));
|
|
65
|
+
isolated.clear();
|
|
66
|
+
});
|
|
67
|
+
}
|
package/src/blocking-layer.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* rectangle (the hole). Inside the hole the layer isn't painted and hit-testing passes through,
|
|
9
9
|
* so the toggle can be clicked natively without touching z-index at all. Unlike approaches that
|
|
10
10
|
* shuffle z-index, this doesn't break depending on ancestor stacking contexts.
|
|
11
|
-
* The hole is updated via
|
|
11
|
+
* The hole is updated via watchReference (a per-frame rAF tracker) to follow the toggle's scroll/resize.
|
|
12
12
|
*
|
|
13
13
|
* 2. Focus containment:
|
|
14
14
|
* On ON, blur activeElement, and via focusin (capture) detect focus moving to anything other
|
package/src/dom-builder.js
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Accessibility:
|
|
6
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)
|
|
7
|
+
* - The popup uses role="dialog" + aria-labelledby (the title element) so assistive tech announces it,
|
|
8
|
+
* plus aria-describedby (the body element) so the description text is read out, not just the title.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// Each initHelpLayer instance builds its own popup; a fixed id would collide when the
|
|
12
|
+
// library is initialized more than once on a page (invalid duplicate id + ambiguous
|
|
13
|
+
// aria-labelledby). Hand out a unique id per popup instead.
|
|
14
|
+
let popupSeq = 0;
|
|
11
15
|
|
|
12
16
|
export function createBlockingLayer() {
|
|
13
17
|
const layer = document.createElement('div');
|
|
@@ -33,18 +37,32 @@ export function createMarker(title, label = '?') {
|
|
|
33
37
|
* Also returns references to titleEl/textEl (used to update the content) and the close button closeEl.
|
|
34
38
|
*/
|
|
35
39
|
export function createPopup() {
|
|
40
|
+
// One sequence value per popup, shared by the title and body ids (then advanced once), so two
|
|
41
|
+
// instances on a page never collide on either id.
|
|
42
|
+
const seq = popupSeq++;
|
|
43
|
+
const titleId = `help-layer-popup-title-${seq}`;
|
|
44
|
+
const textId = `help-layer-popup-text-${seq}`;
|
|
45
|
+
|
|
36
46
|
const root = document.createElement('div');
|
|
37
47
|
root.className = 'help-layer-popup';
|
|
38
48
|
root.setAttribute('role', 'dialog');
|
|
39
|
-
|
|
49
|
+
// aria-modal tells AT that content outside the dialog is inert while it's shown (the host is also
|
|
50
|
+
// inert'd at the document level during help mode). Harmless when hidden: display:none drops the
|
|
51
|
+
// popup from the a11y tree.
|
|
52
|
+
root.setAttribute('aria-modal', 'true');
|
|
53
|
+
root.setAttribute('aria-labelledby', titleId);
|
|
54
|
+
// Point at the body container (not its contents) so the description is announced even after a custom
|
|
55
|
+
// render swaps the body's children — the container id stays stable.
|
|
56
|
+
root.setAttribute('aria-describedby', textId);
|
|
40
57
|
root.tabIndex = -1;
|
|
41
58
|
|
|
42
59
|
const titleEl = document.createElement('div');
|
|
43
60
|
titleEl.className = 'help-layer-popup__title';
|
|
44
|
-
titleEl.id =
|
|
61
|
+
titleEl.id = titleId;
|
|
45
62
|
|
|
46
63
|
const textEl = document.createElement('div');
|
|
47
64
|
textEl.className = 'help-layer-popup__text';
|
|
65
|
+
textEl.id = textId;
|
|
48
66
|
|
|
49
67
|
// Explicit close affordance. Wiring the click is popup.js's job (only element creation here).
|
|
50
68
|
const closeEl = document.createElement('button');
|