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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alternate positioning backend built on Floating UI (@floating-ui/dom).
|
|
3
|
+
*
|
|
4
|
+
* This is the original implementation, kept verbatim so the library can switch back to Floating UI at
|
|
5
|
+
* any time. To revert:
|
|
6
|
+
* 1. in floating.js, change the re-export to `export * from './floating.floatingui.js';`
|
|
7
|
+
* 2. move `@floating-ui/dom` from devDependencies back to dependencies
|
|
8
|
+
* 3. restore the `@floating-ui/*` entries in the demo import maps (demo/index.html, demo/stress.html)
|
|
9
|
+
* The default backend is the dependency-free ./floating.self.js. The reference helpers
|
|
10
|
+
* (isFixedReference / isReferenceHidden / makeVirtualElement) are shared via ./reference.js, so both
|
|
11
|
+
* backends expose an identical public surface.
|
|
12
|
+
*
|
|
13
|
+
* Floating UI in a nutshell:
|
|
14
|
+
* - computePosition(reference, floating, options) returns the optimal (x,y) for "this moment".
|
|
15
|
+
* - autoUpdate(reference, floating, update) re-runs update on scroll/resize/element-size changes
|
|
16
|
+
* (internally via ResizeObserver, etc.); the returned cleanup stops watching.
|
|
17
|
+
*/
|
|
18
|
+
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
|
|
19
|
+
|
|
20
|
+
import { isFixedReference, isReferenceHidden, makeVirtualElement } from './reference.js';
|
|
21
|
+
|
|
22
|
+
// Re-export the shared reference helpers so this backend's surface matches floating.self.js exactly.
|
|
23
|
+
export { isFixedReference, isReferenceHidden, makeVirtualElement };
|
|
24
|
+
|
|
25
|
+
function place(el, x, y) {
|
|
26
|
+
el.style.left = `${x}px`;
|
|
27
|
+
el.style.top = `${y}px`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Place the popup below the target, and at screen edges use flip / shift to avoid clipping.
|
|
32
|
+
* @param {Element|object} reference
|
|
33
|
+
* @param {HTMLElement} popupEl
|
|
34
|
+
* @param {import('./types.js').Placement} [placement] initial placement. Default 'bottom-start'
|
|
35
|
+
* @returns {{ update: () => void, cleanup: () => void }}
|
|
36
|
+
*/
|
|
37
|
+
export function anchorPopup(reference, popupEl, placement = 'bottom-start') {
|
|
38
|
+
// The reference is the clicked marker. If it's fixed (anchored to a fixed target), the popup must be
|
|
39
|
+
// fixed too or it jitters on scroll. Set position every open so reopening on a normal marker restores
|
|
40
|
+
// absolute. Inline !important beats the stylesheet's `position: absolute !important`.
|
|
41
|
+
const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
|
|
42
|
+
popupEl.style.setProperty('position', strategy, 'important');
|
|
43
|
+
const update = () => {
|
|
44
|
+
computePosition(reference, popupEl, {
|
|
45
|
+
placement,
|
|
46
|
+
strategy,
|
|
47
|
+
middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
|
|
48
|
+
}).then(({ x, y }) => {
|
|
49
|
+
place(popupEl, x, y);
|
|
50
|
+
// Swallow per-frame rejections so a stray rejection doesn't reach the host's unhandledrejection
|
|
51
|
+
// handler (e.g. Sentry); this runs every animation frame, so logging would also flood the console.
|
|
52
|
+
}).catch(() => {});
|
|
53
|
+
};
|
|
54
|
+
// animationFrame: true so the popup tracks smoothly: its reference is the marker element, which the
|
|
55
|
+
// markers.js loop moves per frame, so the popup must re-evaluate per frame to stay glued.
|
|
56
|
+
const cleanup = autoUpdate(reference, popupEl, update, { animationFrame: true });
|
|
57
|
+
return { update, cleanup };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Watch a reference element's position/size changes and call onUpdate on every change.
|
|
62
|
+
* (Used to keep the blocking layer's clip-path hole following the toggle.) autoUpdate requires a
|
|
63
|
+
* floating element, so floatingEl is passed as a dummy that onUpdate doesn't actually position.
|
|
64
|
+
* @param {Element} referenceEl
|
|
65
|
+
* @param {HTMLElement} floatingEl
|
|
66
|
+
* @param {() => void} onUpdate
|
|
67
|
+
* @returns {() => void} cleanup
|
|
68
|
+
*/
|
|
69
|
+
export function watchReference(referenceEl, floatingEl, onUpdate) {
|
|
70
|
+
return autoUpdate(referenceEl, floatingEl, onUpdate);
|
|
71
|
+
}
|
package/src/floating.js
CHANGED
|
@@ -1,170 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* makeVirtualElement). That way, if Floating UI is ever swapped out, the blast
|
|
6
|
-
* radius stays limited to here.
|
|
2
|
+
* Positioning seam. Every other module imports positioning from here (anchorPopup / watchReference /
|
|
3
|
+
* makeVirtualElement / isFixedReference / isReferenceHidden), so the backend can be swapped in one
|
|
4
|
+
* place without touching consumers.
|
|
7
5
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
6
|
+
* Default: ./floating.self.js — dependency-free.
|
|
7
|
+
* To switch back to Floating UI, change the line below to:
|
|
8
|
+
* export * from './floating.floatingui.js';
|
|
9
|
+
* and move `@floating-ui/dom` from devDependencies back to dependencies (see that file's header).
|
|
15
10
|
*/
|
|
16
|
-
|
|
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
|
-
/**
|
|
44
|
-
* Whether the reference lives in a `position: fixed` subtree. Such a reference stays put in the
|
|
45
|
-
* viewport while the page scrolls, so an absolutely-positioned floating element (which scrolls with
|
|
46
|
-
* the document) would have to be re-corrected every frame and visibly jitters. For these we switch the
|
|
47
|
-
* floating element to Floating UI's `fixed` strategy (and position:fixed) so both live in the same
|
|
48
|
-
* viewport space and stay glued without per-frame correction.
|
|
49
|
-
*
|
|
50
|
-
* Virtual elements (free placements) aren't in the DOM and already track scroll via their getRect, so
|
|
51
|
-
* they report false. Walks across shadow boundaries via the host so Shadow DOM targets are handled too.
|
|
52
|
-
* @param {Element|object} reference
|
|
53
|
-
*/
|
|
54
|
-
export function isFixedReference(reference) {
|
|
55
|
-
if (!(reference instanceof Element)) {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
let node = reference;
|
|
59
|
-
while (node) {
|
|
60
|
-
if (getComputedStyle(node).position === 'fixed') {
|
|
61
|
-
return true;
|
|
62
|
-
}
|
|
63
|
-
const parent = node.parentElement;
|
|
64
|
-
if (parent) {
|
|
65
|
-
node = parent;
|
|
66
|
-
} else {
|
|
67
|
-
const root = node.getRootNode();
|
|
68
|
-
node = root instanceof ShadowRoot ? root.host : null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// Half of the default marker size (22px). The amount used to overlap the marker onto the
|
|
75
|
-
// target's corner with an "inset". (If the marker-size CSS variable is changed, the resulting
|
|
76
|
-
// drift is left as existing behavior = not compensated for here.)
|
|
77
|
-
const MARKER_INSET = 11;
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Derive the offset for overlapping the marker onto the target's corner from the placement.
|
|
81
|
-
* mainAxis is always negative (bites inward past the target's edge). crossAxis flips sign by
|
|
82
|
-
* alignment direction: `-end` (right/bottom-aligned) is negative to go inward, `-start`
|
|
83
|
-
* (left/top-aligned) is positive to go inward.
|
|
84
|
-
* @param {string} placement
|
|
85
|
-
*/
|
|
86
|
-
function markerOffset(placement) {
|
|
87
|
-
const isStart = placement.endsWith('-start');
|
|
88
|
-
return { mainAxis: -MARKER_INSET, crossAxis: isStart ? MARKER_INSET : -MARKER_INSET };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Overlap the marker onto a corner of the target (element or virtual element), stick it there, and keep it following.
|
|
93
|
-
* @param {Element|object} reference
|
|
94
|
-
* @param {HTMLElement} markerEl
|
|
95
|
-
* @param {() => void} [onPlaced] called every time placement is finalized (used to trigger the overlap-avoidance pass, etc.)
|
|
96
|
-
* @param {import('@floating-ui/dom').Placement} [placement] corner to overlap (top-end/top-start/bottom-end/bottom-start). Default 'top-end'
|
|
97
|
-
* @returns {() => void} cleanup
|
|
98
|
-
*/
|
|
99
|
-
export function anchorMarker(reference, markerEl, onPlaced, placement = 'top-end') {
|
|
100
|
-
// Match the floating element's strategy to the reference: a fixed reference needs a fixed marker, or
|
|
101
|
-
// it jitters while scrolling (see isFixedReference). Inline !important beats the stylesheet's
|
|
102
|
-
// `position: absolute !important`.
|
|
103
|
-
const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
|
|
104
|
-
if (strategy === 'fixed') {
|
|
105
|
-
markerEl.style.setProperty('position', 'fixed', 'important');
|
|
106
|
-
}
|
|
107
|
-
const update = () => {
|
|
108
|
-
computePosition(reference, markerEl, {
|
|
109
|
-
placement,
|
|
110
|
-
strategy,
|
|
111
|
-
middleware: [offset(markerOffset(placement))],
|
|
112
|
-
}).then(({ x, y }) => {
|
|
113
|
-
place(markerEl, x, y);
|
|
114
|
-
if (onPlaced) {
|
|
115
|
-
onPlaced();
|
|
116
|
-
}
|
|
117
|
-
// Swallow silently: this runs every animation frame, so logging would flood the console, and a
|
|
118
|
-
// stray rejection must not surface in the host app's unhandledrejection handler (e.g. Sentry).
|
|
119
|
-
}).catch(() => {});
|
|
120
|
-
};
|
|
121
|
-
// animationFrame: true syncs repositioning to the rAF loop. With the default (scroll/resize
|
|
122
|
-
// events only), computePosition resolves asynchronously, so left/top is written the frame after
|
|
123
|
-
// the browser already painted the scroll — the marker lags a frame and visibly jitters.
|
|
124
|
-
return autoUpdate(reference, markerEl, update, { animationFrame: true });
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Place the popup below the target, and at screen edges use flip (flip to the opposite side) /
|
|
129
|
-
* shift (nudge) to avoid clipping. Only follows while visible.
|
|
130
|
-
* @param {Element|object} reference
|
|
131
|
-
* @param {HTMLElement} popupEl
|
|
132
|
-
* @param {import('@floating-ui/dom').Placement} [placement] initial placement (Floating UI placement). Default 'bottom-start'
|
|
133
|
-
* @returns {{ update: () => void, cleanup: () => void }}
|
|
134
|
-
* calling update repositions immediately (used for reference-side transform moves that autoUpdate doesn't pick up, etc.).
|
|
135
|
-
*/
|
|
136
|
-
export function anchorPopup(reference, popupEl, placement = 'bottom-start') {
|
|
137
|
-
// The reference is the clicked marker. If it's fixed (anchored to a fixed target), the popup must be
|
|
138
|
-
// fixed too or it jitters on scroll. Set position every open so reopening on a normal marker restores
|
|
139
|
-
// absolute. Inline !important beats the stylesheet's `position: absolute !important`.
|
|
140
|
-
const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
|
|
141
|
-
popupEl.style.setProperty('position', strategy, 'important');
|
|
142
|
-
const update = () => {
|
|
143
|
-
computePosition(reference, popupEl, {
|
|
144
|
-
placement,
|
|
145
|
-
strategy,
|
|
146
|
-
middleware: [offset(8), flip({ padding: 8 }), shift({ padding: 8 })],
|
|
147
|
-
}).then(({ x, y }) => {
|
|
148
|
-
place(popupEl, x, y);
|
|
149
|
-
// Same rationale as anchorMarker: swallow per-frame rejections so they don't reach the host.
|
|
150
|
-
}).catch(() => {});
|
|
151
|
-
};
|
|
152
|
-
// animationFrame: true for the same smooth-tracking reason as anchorMarker. The reference here is
|
|
153
|
-
// the marker element, which itself moves per frame, so the popup must track per frame to stay glued.
|
|
154
|
-
const cleanup = autoUpdate(reference, popupEl, update, { animationFrame: true });
|
|
155
|
-
return { update, cleanup };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Watch a reference element's position/size changes and call onUpdate on every change.
|
|
160
|
-
* (Used for non-placement purposes, e.g. keeping the blocking layer's clip-path hole following the toggle position.)
|
|
161
|
-
* autoUpdate requires a floating element, so floatingEl is just passed as a dummy that
|
|
162
|
-
* onUpdate doesn't actually position.
|
|
163
|
-
* @param {Element} referenceEl
|
|
164
|
-
* @param {HTMLElement} floatingEl
|
|
165
|
-
* @param {() => void} onUpdate
|
|
166
|
-
* @returns {() => void} cleanup
|
|
167
|
-
*/
|
|
168
|
-
export function watchReference(referenceEl, floatingEl, onUpdate) {
|
|
169
|
-
return autoUpdate(referenceEl, floatingEl, onUpdate);
|
|
170
|
-
}
|
|
11
|
+
export * from './floating.self.js';
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default positioning backend — dependency-free (no Floating UI).
|
|
3
|
+
*
|
|
4
|
+
* Markers position themselves in markers.js; this module only handles the single shared popup and the
|
|
5
|
+
* blocking layer's toggle-following clip-path. It re-implements the small slice of Floating UI the
|
|
6
|
+
* library used: place-on-a-side + offset + flip + shift (see computePopupPosition in geometry.js), and
|
|
7
|
+
* an autoUpdate-style tracker (track) built on the same per-frame rAF pattern markers.js uses.
|
|
8
|
+
*
|
|
9
|
+
* The reference helpers (isFixedReference / isReferenceHidden / makeVirtualElement) live in
|
|
10
|
+
* reference.js and are re-exported so this backend and floating.floatingui.js have an identical
|
|
11
|
+
* surface — the two are interchangeable via the one-line seam in floating.js.
|
|
12
|
+
*/
|
|
13
|
+
import { computePopupPosition, viewportToAbsolute } from './geometry.js';
|
|
14
|
+
import { isFixedReference, isReferenceHidden, makeVirtualElement } from './reference.js';
|
|
15
|
+
|
|
16
|
+
export { isFixedReference, isReferenceHidden, makeVirtualElement };
|
|
17
|
+
|
|
18
|
+
function place(el, x, y) {
|
|
19
|
+
el.style.left = `${x}px`;
|
|
20
|
+
el.style.top = `${y}px`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameRect(a, b) {
|
|
24
|
+
return a.top === b.top && a.left === b.left && a.width === b.width && a.height === b.height;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Keep calling onChange while the reference moves/resizes: once synchronously now (like Floating UI's
|
|
29
|
+
* initial autoUpdate run), then on every animation frame in which the reference's rect changed. The
|
|
30
|
+
* popup's reference is the marker, which markers.js moves per frame, so per-frame tracking keeps the
|
|
31
|
+
* popup glued; an unchanged rect costs only one getBoundingClientRect and no work.
|
|
32
|
+
* @param {Element|object} reference
|
|
33
|
+
* @param {() => void} onChange
|
|
34
|
+
* @returns {() => void} cleanup
|
|
35
|
+
*/
|
|
36
|
+
function track(reference, onChange) {
|
|
37
|
+
onChange(); // initial placement, synchronous
|
|
38
|
+
let prev = reference.getBoundingClientRect();
|
|
39
|
+
let stopped = false;
|
|
40
|
+
let frame = requestAnimationFrame(function loop() {
|
|
41
|
+
if (stopped) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const rect = reference.getBoundingClientRect();
|
|
45
|
+
if (!sameRect(rect, prev)) {
|
|
46
|
+
onChange();
|
|
47
|
+
}
|
|
48
|
+
prev = rect;
|
|
49
|
+
frame = requestAnimationFrame(loop);
|
|
50
|
+
});
|
|
51
|
+
// A viewport resize can change the popup's flip/shift even when the reference itself doesn't move
|
|
52
|
+
// (e.g. a popup shifted near the right edge while its marker stays put). The rect-change loop above
|
|
53
|
+
// wouldn't catch that, so listen for resize explicitly — matching the Floating UI backend, whose
|
|
54
|
+
// autoUpdate also reacts to ancestor/window resize.
|
|
55
|
+
const onResize = () => onChange();
|
|
56
|
+
window.addEventListener('resize', onResize);
|
|
57
|
+
return () => {
|
|
58
|
+
stopped = true;
|
|
59
|
+
cancelAnimationFrame(frame);
|
|
60
|
+
window.removeEventListener('resize', onResize);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Place the popup on a side of the target with a gap, flipping/shifting at screen edges to stay
|
|
66
|
+
* visible, and keep it following while open.
|
|
67
|
+
* @param {Element|object} reference
|
|
68
|
+
* @param {HTMLElement} popupEl
|
|
69
|
+
* @param {import('./types.js').Placement} [placement] initial placement. Default 'bottom-start'
|
|
70
|
+
* @returns {{ update: () => void, cleanup: () => void }}
|
|
71
|
+
*/
|
|
72
|
+
export function anchorPopup(reference, popupEl, placement = 'bottom-start') {
|
|
73
|
+
// Match the strategy to the reference: a fixed reference needs a fixed popup or it jitters on
|
|
74
|
+
// scroll. Set it every open so reopening on a normal marker restores absolute. Inline !important
|
|
75
|
+
// beats the stylesheet's `position: absolute !important`.
|
|
76
|
+
const strategy = isFixedReference(reference) ? 'fixed' : 'absolute';
|
|
77
|
+
popupEl.style.setProperty('position', strategy, 'important');
|
|
78
|
+
|
|
79
|
+
const update = () => {
|
|
80
|
+
const refRect = reference.getBoundingClientRect();
|
|
81
|
+
const popupSize = { width: popupEl.offsetWidth, height: popupEl.offsetHeight };
|
|
82
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
83
|
+
const { left, top } = computePopupPosition(refRect, popupSize, viewport, { placement });
|
|
84
|
+
if (strategy === 'fixed') {
|
|
85
|
+
place(popupEl, left, top); // fixed: viewport coordinates are used as-is
|
|
86
|
+
} else {
|
|
87
|
+
// absolute: convert viewport coords to body-relative (same scroll-invariant trick as markers).
|
|
88
|
+
const body = document.body;
|
|
89
|
+
const abs = viewportToAbsolute(left, top, body.getBoundingClientRect(), body.clientLeft, body.clientTop);
|
|
90
|
+
place(popupEl, abs.left, abs.top);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const cleanup = track(reference, update);
|
|
95
|
+
return { update, cleanup };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Watch a reference element's position/size changes and call onUpdate on every change.
|
|
100
|
+
* (Used to keep the blocking layer's clip-path hole following the toggle.) floatingEl is accepted for
|
|
101
|
+
* signature parity with the Floating UI backend but isn't used here.
|
|
102
|
+
* @param {Element} referenceEl
|
|
103
|
+
* @param {HTMLElement} _floatingEl unused (kept for backend parity)
|
|
104
|
+
* @param {() => void} onUpdate
|
|
105
|
+
* @returns {() => void} cleanup
|
|
106
|
+
*/
|
|
107
|
+
export function watchReference(referenceEl, _floatingEl, onUpdate) {
|
|
108
|
+
return track(referenceEl, onUpdate);
|
|
109
|
+
}
|
package/src/geometry.js
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
|
/**
|
|
@@ -18,8 +18,8 @@ export function toDocumentPosition(rect, scroll) {
|
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
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
|
|
22
|
-
*
|
|
21
|
+
* the current scroll offset. This is what the getBoundingClientRect of a virtual
|
|
22
|
+
* reference element (a free-placement marker) returns.
|
|
23
23
|
* @param {{top:number,left:number,width?:number,height?:number}} docRect
|
|
24
24
|
* @param {{x:number,y:number}} scroll
|
|
25
25
|
*/
|
|
@@ -39,3 +39,167 @@ export function docRectToViewportRect(docRect, scroll) {
|
|
|
39
39
|
height,
|
|
40
40
|
};
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
// Half of the default marker size (24px). The marker bites this far inward past the target's edge so
|
|
44
|
+
// it overlaps the corner with an "inset". (If the marker-size CSS variable is changed, the marker
|
|
45
|
+
// size is read at runtime in markers.js, but this inset stays fixed = existing behavior.)
|
|
46
|
+
export const MARKER_INSET = 12;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute a marker's top-left in viewport coordinates so it overlaps a corner of the reference rect,
|
|
50
|
+
* replicating what Floating UI's computePosition(placement) + offset(markerOffset) produced before.
|
|
51
|
+
*
|
|
52
|
+
* placement is "<side>" or "<side>-<align>" where side is top/bottom/left/right and align is
|
|
53
|
+
* start/end (omitted = centered). The marker is a square of the given size. mainAxis bites INSET
|
|
54
|
+
* inward (overlapping the target edge); crossAxis nudges INSET inward from the aligned edge.
|
|
55
|
+
* @param {{top:number,left:number,width:number,height:number}} refRect viewport rect of the target
|
|
56
|
+
* @param {number} size marker width/height (square)
|
|
57
|
+
* @param {string} placement a placement string (see Placement in types.js; default 'top-end')
|
|
58
|
+
* @returns {{left:number, top:number}} viewport coordinates of the marker's top-left corner
|
|
59
|
+
*/
|
|
60
|
+
export function markerViewportTopLeft(refRect, size, placement = 'top-end') {
|
|
61
|
+
const [side, align] = placement.split('-');
|
|
62
|
+
const isStart = align === 'start';
|
|
63
|
+
// crossAxis: -end (right/bottom-aligned) goes inward negative; -start goes inward positive.
|
|
64
|
+
const cross = isStart ? MARKER_INSET : -MARKER_INSET;
|
|
65
|
+
|
|
66
|
+
let left;
|
|
67
|
+
let top;
|
|
68
|
+
if (side === 'top' || side === 'bottom') {
|
|
69
|
+
// Vertical placement: main axis is Y, cross axis is X.
|
|
70
|
+
top = side === 'top'
|
|
71
|
+
? refRect.top - size + MARKER_INSET // above, then bite down into the target
|
|
72
|
+
: refRect.top + refRect.height - MARKER_INSET; // below, then bite up
|
|
73
|
+
if (align === 'start') {
|
|
74
|
+
left = refRect.left;
|
|
75
|
+
} else if (align === 'end') {
|
|
76
|
+
left = refRect.left + refRect.width - size;
|
|
77
|
+
} else {
|
|
78
|
+
left = refRect.left + refRect.width / 2 - size / 2;
|
|
79
|
+
}
|
|
80
|
+
left += cross;
|
|
81
|
+
} else {
|
|
82
|
+
// Horizontal placement (left/right): main axis is X, cross axis is Y.
|
|
83
|
+
left = side === 'left'
|
|
84
|
+
? refRect.left - size + MARKER_INSET
|
|
85
|
+
: refRect.left + refRect.width - MARKER_INSET;
|
|
86
|
+
if (align === 'start') {
|
|
87
|
+
top = refRect.top;
|
|
88
|
+
} else if (align === 'end') {
|
|
89
|
+
top = refRect.top + refRect.height - size;
|
|
90
|
+
} else {
|
|
91
|
+
top = refRect.top + refRect.height / 2 - size / 2;
|
|
92
|
+
}
|
|
93
|
+
top += cross;
|
|
94
|
+
}
|
|
95
|
+
return { left, top };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convert a viewport coordinate into the left/top to set on a `position:absolute` element whose
|
|
100
|
+
* offsetParent is `document.body`. Both the marker's reference rect and the body rect come from
|
|
101
|
+
* getBoundingClientRect (viewport space), so their difference is scroll-invariant — which is exactly
|
|
102
|
+
* why the marker stays anchored to its target as the page scrolls. clientLeft/clientTop subtract the
|
|
103
|
+
* body's border so the offset is measured from the body's padding-box origin (the absolute origin).
|
|
104
|
+
* For `position:fixed` markers no conversion is needed (viewport coordinates are used as-is).
|
|
105
|
+
* @param {number} vx viewport x
|
|
106
|
+
* @param {number} vy viewport y
|
|
107
|
+
* @param {{left:number, top:number}} bodyRect document.body's getBoundingClientRect
|
|
108
|
+
* @param {number} clientLeft document.body.clientLeft (left border width)
|
|
109
|
+
* @param {number} clientTop document.body.clientTop (top border width)
|
|
110
|
+
* @returns {{left:number, top:number}}
|
|
111
|
+
*/
|
|
112
|
+
export function viewportToAbsolute(vx, vy, bodyRect, clientLeft, clientTop) {
|
|
113
|
+
return {
|
|
114
|
+
left: vx - bodyRect.left - clientLeft,
|
|
115
|
+
top: vy - bodyRect.top - clientTop,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Compute the popup's top-left in viewport coordinates, replicating the small subset of Floating UI
|
|
123
|
+
* we relied on: place on a side of the reference with a gap (offset), flip to the opposite side when
|
|
124
|
+
* the preferred side doesn't fit (main axis), and shift along the cross axis to keep it inside the
|
|
125
|
+
* viewport (with padding). This covers the popup's case — a single element over document.body whose
|
|
126
|
+
* clipping boundary is the viewport — and is intentionally simpler than Floating UI (no nested
|
|
127
|
+
* clipping ancestors / transforms / RTL).
|
|
128
|
+
*
|
|
129
|
+
* placement is "<side>" or "<side>-<align>" (side: top/bottom/left/right; align: start/end, omitted = center).
|
|
130
|
+
* @param {{top:number,left:number,width:number,height:number}} refRect viewport rect of the reference
|
|
131
|
+
* @param {{width:number,height:number}} popupSize the popup's measured size
|
|
132
|
+
* @param {{width:number,height:number}} viewport innerWidth/innerHeight
|
|
133
|
+
* @param {object} [opts]
|
|
134
|
+
* @param {string} [opts.placement] default 'bottom-start'
|
|
135
|
+
* @param {number} [opts.offset] gap between reference and popup along the main axis (default 8)
|
|
136
|
+
* @param {number} [opts.padding] minimum gap kept from the viewport edges (default 8)
|
|
137
|
+
* @returns {{left:number, top:number, placement:string}} resolved viewport coords + the side actually used
|
|
138
|
+
*/
|
|
139
|
+
export function computePopupPosition(refRect, popupSize, viewport, opts = {}) {
|
|
140
|
+
const placement = opts.placement ?? 'bottom-start';
|
|
141
|
+
const offset = opts.offset ?? 8;
|
|
142
|
+
const padding = opts.padding ?? 8;
|
|
143
|
+
const [side, align] = placement.split('-');
|
|
144
|
+
const vertical = side === 'top' || side === 'bottom';
|
|
145
|
+
|
|
146
|
+
// Main-axis position for a given side (top/left coordinate that places the popup on that side).
|
|
147
|
+
const mainPos = (s) => {
|
|
148
|
+
if (s === 'bottom') {
|
|
149
|
+
return refRect.top + refRect.height + offset;
|
|
150
|
+
}
|
|
151
|
+
if (s === 'top') {
|
|
152
|
+
return refRect.top - popupSize.height - offset;
|
|
153
|
+
}
|
|
154
|
+
if (s === 'right') {
|
|
155
|
+
return refRect.left + refRect.width + offset;
|
|
156
|
+
}
|
|
157
|
+
return refRect.left - popupSize.width - offset; // left
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
// Available space (for the popup) on a side, after keeping `padding` from the viewport edge.
|
|
161
|
+
const spaceFor = (s) => {
|
|
162
|
+
if (s === 'bottom') {
|
|
163
|
+
return viewport.height - padding - (refRect.top + refRect.height + offset);
|
|
164
|
+
}
|
|
165
|
+
if (s === 'top') {
|
|
166
|
+
return refRect.top - offset - padding;
|
|
167
|
+
}
|
|
168
|
+
if (s === 'right') {
|
|
169
|
+
return viewport.width - padding - (refRect.left + refRect.width + offset);
|
|
170
|
+
}
|
|
171
|
+
return refRect.left - offset - padding; // left
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Flip (main axis): if the preferred side can't fit the popup, use whichever side has more room.
|
|
175
|
+
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[side];
|
|
176
|
+
const need = vertical ? popupSize.height : popupSize.width;
|
|
177
|
+
const chosen = spaceFor(side) >= need || spaceFor(side) >= spaceFor(opposite) ? side : opposite;
|
|
178
|
+
|
|
179
|
+
// Cross-axis position from the alignment.
|
|
180
|
+
const crossPos = (extentRef, extentPopup, start) => {
|
|
181
|
+
if (align === 'start') {
|
|
182
|
+
return start;
|
|
183
|
+
}
|
|
184
|
+
if (align === 'end') {
|
|
185
|
+
return start + extentRef - extentPopup;
|
|
186
|
+
}
|
|
187
|
+
return start + extentRef / 2 - extentPopup / 2;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
let left;
|
|
191
|
+
let top;
|
|
192
|
+
if (vertical) {
|
|
193
|
+
top = mainPos(chosen);
|
|
194
|
+
left = crossPos(refRect.width, popupSize.width, refRect.left);
|
|
195
|
+
// Shift along x to keep the popup within the viewport.
|
|
196
|
+
left = clamp(left, padding, viewport.width - popupSize.width - padding);
|
|
197
|
+
} else {
|
|
198
|
+
left = mainPos(chosen);
|
|
199
|
+
top = crossPos(refRect.height, popupSize.height, refRect.top);
|
|
200
|
+
// Shift along y to keep the popup within the viewport.
|
|
201
|
+
top = clamp(top, padding, viewport.height - popupSize.height - padding);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return { left, top, placement: align ? `${chosen}-${align}` : chosen };
|
|
205
|
+
}
|
package/src/index.js
CHANGED
|
@@ -20,8 +20,8 @@ import { createToggleController } from './toggle.js';
|
|
|
20
20
|
* Return a Node to display it; if nothing is returned, fall back to safe text display (the title is always record.title).
|
|
21
21
|
* ⚠️ The return value is inserted as-is without sanitization. If it contains untrusted data, neutralize it on the caller side (XSS prevention)
|
|
22
22
|
* @param {string} [options.markerLabel] - character shown on the marker (default '?')
|
|
23
|
-
* @param {import('
|
|
24
|
-
* @param {import('
|
|
23
|
+
* @param {import('./types.js').Placement} [options.markerPlacement] - corner to overlap the marker onto (default 'top-end')
|
|
24
|
+
* @param {import('./types.js').Placement} [options.popupPlacement] - initial popup placement (default 'bottom-start')
|
|
25
25
|
* @param {string} [options.nonce] - nonce to allow the injected <style> under a strict CSP (style-src 'nonce-…')
|
|
26
26
|
* @returns {{
|
|
27
27
|
* enable(): void,
|