@nepdatejs/dom 0.0.0-bootstrap → 0.1.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 +21 -0
- package/README.md +25 -1
- package/dist/index.cjs +191 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +90 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/package.json +57 -4
- package/src/env.ts +22 -0
- package/src/events.ts +118 -0
- package/src/index.ts +24 -0
- package/src/position.ts +180 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shishir Rai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
1
|
# @nepdatejs/dom
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Small browser helpers shared by the `@nepdatejs` adapters. It knows nothing about dates and depends on no package. Every helper is a safe no-op during server rendering, and the ones that listen return a cleanup function.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { onEscape, onOutsidePointer, trackPosition } from '@nepdatejs/dom';
|
|
7
|
+
|
|
8
|
+
const stop = [
|
|
9
|
+
trackPosition(input, panel), // top layer when supported; flips and stays on screen
|
|
10
|
+
onOutsidePointer([field, panel], close),
|
|
11
|
+
onEscape(close), // capture phase, so an enclosing dialog stays open
|
|
12
|
+
];
|
|
13
|
+
// later: stop.forEach((cleanup) => cleanup());
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
| Helper | Does |
|
|
17
|
+
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
18
|
+
| `computePosition(anchorRect, panelHeight, viewport, options)` | Pure placement maths. |
|
|
19
|
+
| `placePopover(anchor, panel, options)` / `trackPosition(…)` | Show and position (and keep positioned); `layout: 'auto' \| 'popover' \| 'sheet'`, `sheetBelow` (480). |
|
|
20
|
+
| `onOutsidePointer(elements, callback)` | Pointer down outside the elements. |
|
|
21
|
+
| `onEscape(callback, { capture, stop })` | Escape key. |
|
|
22
|
+
| `onBackdropPointer(panel, callback)` | Tap on a bottom sheet's backdrop. |
|
|
23
|
+
| `resolveLayout(layout, viewportWidth, sheetBelow?)` | `'popover'` or `'sheet'` for a width. |
|
|
24
|
+
| `focusFirst(container, selector?)`, `restoreFocus(element)` | Focus helpers. |
|
|
25
|
+
| `supportsPopover()`, `prefersReducedMotion()`, `hasDom()` | Feature checks. |
|
|
26
|
+
|
|
27
|
+
MIT © Shishir Rai
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/env.ts
|
|
3
|
+
/** True in a browser-like environment; false during server rendering. */
|
|
4
|
+
function hasDom() {
|
|
5
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
6
|
+
}
|
|
7
|
+
/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */
|
|
8
|
+
function supportsPopover() {
|
|
9
|
+
return hasDom() && typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.showPopover === "function";
|
|
10
|
+
}
|
|
11
|
+
/** Whether the user asked the system for less motion. */
|
|
12
|
+
function prefersReducedMotion() {
|
|
13
|
+
return hasDom() && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/position.ts
|
|
17
|
+
/** The layout to use for a viewport width. Pure. */
|
|
18
|
+
function resolveLayout(layout, viewportWidth, sheetBelow = 480) {
|
|
19
|
+
if (layout !== void 0 && layout !== "auto") return layout;
|
|
20
|
+
return viewportWidth < sheetBelow ? "sheet" : "popover";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Where to put a panel next to an anchor, in viewport (position: fixed)
|
|
24
|
+
* coordinates. Pure, so it can be tested without a browser. Rules from the
|
|
25
|
+
* institute-SaaS picker: prefer the requested side, flip when the panel does
|
|
26
|
+
* not fit but does fit on the other side, and keep `gap` px from every edge
|
|
27
|
+
* (the panel may overlap the anchor rather than leave the screen).
|
|
28
|
+
*/
|
|
29
|
+
function computePosition(anchor, panelHeight, viewport, options = {}) {
|
|
30
|
+
const gap = options.gap ?? 6;
|
|
31
|
+
const placement = options.placement ?? "bottom-start";
|
|
32
|
+
const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));
|
|
33
|
+
const roomBelow = viewport.height - anchor.bottom - gap * 2;
|
|
34
|
+
const roomAbove = anchor.top - gap * 2;
|
|
35
|
+
const side = placement.startsWith("top") ? roomAbove >= panelHeight || roomAbove >= roomBelow ? "top" : "bottom" : roomBelow >= panelHeight || roomBelow >= roomAbove ? "bottom" : "top";
|
|
36
|
+
const rawTop = side === "bottom" ? anchor.bottom + gap : anchor.top - gap - panelHeight;
|
|
37
|
+
const rawLeft = placement.endsWith("end") ? anchor.right - width : anchor.left;
|
|
38
|
+
return {
|
|
39
|
+
top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),
|
|
40
|
+
left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),
|
|
41
|
+
width,
|
|
42
|
+
side
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Show `panel` beside `anchor` once. Uses the browser's top layer when it can
|
|
47
|
+
* (so dialogs and scrolling containers never clip the panel), otherwise
|
|
48
|
+
* position: fixed. Returns a function that hides the panel again.
|
|
49
|
+
*/
|
|
50
|
+
function placePopover(anchor, panel, options = {}) {
|
|
51
|
+
if (!hasDom()) return noop$1;
|
|
52
|
+
const topLayer = supportsPopover();
|
|
53
|
+
if (topLayer) {
|
|
54
|
+
if (!panel.hasAttribute("popover")) panel.setAttribute("popover", "manual");
|
|
55
|
+
if (!panel.matches(":popover-open")) panel.showPopover();
|
|
56
|
+
}
|
|
57
|
+
const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);
|
|
58
|
+
panel.dataset["layout"] = layout;
|
|
59
|
+
if (layout === "sheet") {
|
|
60
|
+
Object.assign(panel.style, {
|
|
61
|
+
top: "",
|
|
62
|
+
left: "",
|
|
63
|
+
width: "",
|
|
64
|
+
position: "fixed",
|
|
65
|
+
margin: "0",
|
|
66
|
+
inset: "auto 0 0 0"
|
|
67
|
+
});
|
|
68
|
+
delete panel.dataset["side"];
|
|
69
|
+
return hider(panel, topLayer);
|
|
70
|
+
}
|
|
71
|
+
const position = computePosition(anchor.getBoundingClientRect(), panel.offsetHeight, {
|
|
72
|
+
width: window.innerWidth,
|
|
73
|
+
height: window.innerHeight
|
|
74
|
+
}, options);
|
|
75
|
+
Object.assign(panel.style, {
|
|
76
|
+
position: "fixed",
|
|
77
|
+
margin: "0",
|
|
78
|
+
inset: "auto",
|
|
79
|
+
top: `${position.top}px`,
|
|
80
|
+
left: `${position.left}px`,
|
|
81
|
+
width: `${position.width}px`
|
|
82
|
+
});
|
|
83
|
+
panel.dataset["side"] = position.side;
|
|
84
|
+
return hider(panel, topLayer);
|
|
85
|
+
}
|
|
86
|
+
function hider(panel, topLayer) {
|
|
87
|
+
return () => {
|
|
88
|
+
if (topLayer && panel.matches(":popover-open")) panel.hidePopover();
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Show the panel and keep it beside the anchor while the window resizes or
|
|
93
|
+
* any ancestor scrolls. Returns a function that stops tracking and hides it.
|
|
94
|
+
*/
|
|
95
|
+
function trackPosition(anchor, panel, options = {}) {
|
|
96
|
+
if (!hasDom()) return noop$1;
|
|
97
|
+
let hide = placePopover(anchor, panel, options);
|
|
98
|
+
const place = () => {
|
|
99
|
+
hide = placePopover(anchor, panel, options);
|
|
100
|
+
};
|
|
101
|
+
window.addEventListener("resize", place);
|
|
102
|
+
window.addEventListener("scroll", place, true);
|
|
103
|
+
return () => {
|
|
104
|
+
window.removeEventListener("resize", place);
|
|
105
|
+
window.removeEventListener("scroll", place, true);
|
|
106
|
+
hide();
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function noop$1() {}
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/events.ts
|
|
112
|
+
/**
|
|
113
|
+
* Call `callback` when a pointer goes down outside all of `elements` (checked
|
|
114
|
+
* in the capture phase, before the page reacts). Returns a cleanup function.
|
|
115
|
+
*/
|
|
116
|
+
function onOutsidePointer(elements, callback) {
|
|
117
|
+
if (!hasDom()) return noop;
|
|
118
|
+
const listener = (event) => {
|
|
119
|
+
const target = event.target;
|
|
120
|
+
const list = typeof elements === "function" ? elements() : elements;
|
|
121
|
+
if (!(target instanceof Node && list.some((element) => element?.contains(target) ?? false))) callback(event);
|
|
122
|
+
};
|
|
123
|
+
document.addEventListener("pointerdown", listener, true);
|
|
124
|
+
return () => {
|
|
125
|
+
document.removeEventListener("pointerdown", listener, true);
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Call `callback` when the pointer goes down on a panel's backdrop (the dimmed
|
|
130
|
+
* area around a bottom sheet). A top-layer panel receives those events itself,
|
|
131
|
+
* so they are told apart by position: on the panel, but outside its box.
|
|
132
|
+
*/
|
|
133
|
+
function onBackdropPointer(panel, callback) {
|
|
134
|
+
if (!hasDom()) return noop;
|
|
135
|
+
const listener = (event) => {
|
|
136
|
+
if (event.target !== panel) return;
|
|
137
|
+
const box = panel.getBoundingClientRect();
|
|
138
|
+
if (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom) callback(event);
|
|
139
|
+
};
|
|
140
|
+
panel.addEventListener("pointerdown", listener);
|
|
141
|
+
return () => {
|
|
142
|
+
panel.removeEventListener("pointerdown", listener);
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Call `callback` on Escape. By default it listens in the capture phase and
|
|
147
|
+
* stops the event, so an enclosing dialog does not close as well.
|
|
148
|
+
*/
|
|
149
|
+
function onEscape(callback, options = {}) {
|
|
150
|
+
if (!hasDom()) return noop;
|
|
151
|
+
const capture = options.capture ?? true;
|
|
152
|
+
const listener = (event) => {
|
|
153
|
+
if (event.key !== "Escape") return;
|
|
154
|
+
if (options.stop ?? true) {
|
|
155
|
+
event.stopPropagation();
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
}
|
|
158
|
+
callback(event);
|
|
159
|
+
};
|
|
160
|
+
window.addEventListener("keydown", listener, capture);
|
|
161
|
+
return () => {
|
|
162
|
+
window.removeEventListener("keydown", listener, capture);
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */
|
|
166
|
+
function focusFirst(container, selector = "[tabindex=\"0\"]") {
|
|
167
|
+
const element = container?.querySelector(selector);
|
|
168
|
+
if (!element) return false;
|
|
169
|
+
element.focus();
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
/** Put focus back, e.g. on the input after the panel closes. */
|
|
173
|
+
function restoreFocus(element) {
|
|
174
|
+
element?.focus({ preventScroll: true });
|
|
175
|
+
}
|
|
176
|
+
function noop() {}
|
|
177
|
+
//#endregion
|
|
178
|
+
exports.computePosition = computePosition;
|
|
179
|
+
exports.focusFirst = focusFirst;
|
|
180
|
+
exports.hasDom = hasDom;
|
|
181
|
+
exports.onBackdropPointer = onBackdropPointer;
|
|
182
|
+
exports.onEscape = onEscape;
|
|
183
|
+
exports.onOutsidePointer = onOutsidePointer;
|
|
184
|
+
exports.placePopover = placePopover;
|
|
185
|
+
exports.prefersReducedMotion = prefersReducedMotion;
|
|
186
|
+
exports.resolveLayout = resolveLayout;
|
|
187
|
+
exports.restoreFocus = restoreFocus;
|
|
188
|
+
exports.supportsPopover = supportsPopover;
|
|
189
|
+
exports.trackPosition = trackPosition;
|
|
190
|
+
|
|
191
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["noop"],"sources":["../src/env.ts","../src/position.ts","../src/events.ts"],"sourcesContent":["/** True in a browser-like environment; false during server rendering. */\nexport function hasDom(): boolean {\n return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n\n/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */\nexport function supportsPopover(): boolean {\n return (\n hasDom() &&\n typeof HTMLElement !== 'undefined' &&\n typeof HTMLElement.prototype.showPopover === 'function'\n );\n}\n\n/** Whether the user asked the system for less motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n hasDom() &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n","import { hasDom, supportsPopover } from './env';\n\nexport type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';\n\n/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */\nexport type PanelLayout = 'auto' | 'popover' | 'sheet';\n\nexport interface PlaceOptions {\n /** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */\n readonly gap?: number | undefined;\n /** Panel width in px; shrinks to fit narrow screens. Default 320. */\n readonly width?: number | undefined;\n /** Preferred side; flips when there is not enough room. Default 'bottom-start'. */\n readonly placement?: Placement | undefined;\n /** Default 'auto'. */\n readonly layout?: PanelLayout | undefined;\n /** 'auto' uses the sheet below this viewport width, in px. Default 480. */\n readonly sheetBelow?: number | undefined;\n}\n\n/** The layout to use for a viewport width. Pure. */\nexport function resolveLayout(\n layout: PanelLayout | undefined,\n viewportWidth: number,\n sheetBelow = 480,\n): 'popover' | 'sheet' {\n if (layout !== undefined && layout !== 'auto') {\n return layout;\n }\n\n return viewportWidth < sheetBelow ? 'sheet' : 'popover';\n}\n\nexport interface Rect {\n readonly top: number;\n readonly left: number;\n readonly bottom: number;\n readonly right: number;\n}\n\nexport interface Position {\n readonly top: number;\n readonly left: number;\n readonly width: number;\n /** Where the panel ended up after flipping. */\n readonly side: 'top' | 'bottom';\n}\n\n/**\n * Where to put a panel next to an anchor, in viewport (position: fixed)\n * coordinates. Pure, so it can be tested without a browser. Rules from the\n * institute-SaaS picker: prefer the requested side, flip when the panel does\n * not fit but does fit on the other side, and keep `gap` px from every edge\n * (the panel may overlap the anchor rather than leave the screen).\n */\nexport function computePosition(\n anchor: Rect,\n panelHeight: number,\n viewport: { readonly width: number; readonly height: number },\n options: PlaceOptions = {},\n): Position {\n const gap = options.gap ?? 6;\n const placement = options.placement ?? 'bottom-start';\n const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));\n const roomBelow = viewport.height - anchor.bottom - gap * 2;\n const roomAbove = anchor.top - gap * 2;\n const wantsTop = placement.startsWith('top');\n const side: 'top' | 'bottom' = wantsTop\n ? roomAbove >= panelHeight || roomAbove >= roomBelow\n ? 'top'\n : 'bottom'\n : roomBelow >= panelHeight || roomBelow >= roomAbove\n ? 'bottom'\n : 'top';\n const rawTop = side === 'bottom' ? anchor.bottom + gap : anchor.top - gap - panelHeight;\n const rawLeft = placement.endsWith('end') ? anchor.right - width : anchor.left;\n\n return {\n // Keep the whole panel on screen; when it is taller than the room, its top edge wins.\n top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),\n left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),\n width,\n side,\n };\n}\n\n/**\n * Show `panel` beside `anchor` once. Uses the browser's top layer when it can\n * (so dialogs and scrolling containers never clip the panel), otherwise\n * position: fixed. Returns a function that hides the panel again.\n */\nexport function placePopover(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const topLayer = supportsPopover();\n if (topLayer) {\n if (!panel.hasAttribute('popover')) {\n panel.setAttribute('popover', 'manual');\n }\n if (!panel.matches(':popover-open')) {\n panel.showPopover();\n }\n }\n const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);\n panel.dataset['layout'] = layout;\n if (layout === 'sheet') {\n // The stylesheet pins a sheet to the bottom edge; clear any popover coordinates.\n Object.assign(panel.style, {\n top: '',\n left: '',\n width: '',\n position: 'fixed',\n margin: '0',\n inset: 'auto 0 0 0',\n });\n delete panel.dataset['side'];\n\n return hider(panel, topLayer);\n }\n const position = computePosition(\n anchor.getBoundingClientRect(),\n panel.offsetHeight,\n { width: window.innerWidth, height: window.innerHeight },\n options,\n );\n Object.assign(panel.style, {\n position: 'fixed',\n margin: '0',\n inset: 'auto',\n top: `${position.top}px`,\n left: `${position.left}px`,\n width: `${position.width}px`,\n });\n panel.dataset['side'] = position.side;\n\n return hider(panel, topLayer);\n}\n\nfunction hider(panel: HTMLElement, topLayer: boolean): () => void {\n return () => {\n if (topLayer && panel.matches(':popover-open')) {\n panel.hidePopover();\n }\n };\n}\n\n/**\n * Show the panel and keep it beside the anchor while the window resizes or\n * any ancestor scrolls. Returns a function that stops tracking and hides it.\n */\nexport function trackPosition(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n let hide = placePopover(anchor, panel, options);\n const place = (): void => {\n hide = placePopover(anchor, panel, options);\n };\n window.addEventListener('resize', place);\n window.addEventListener('scroll', place, true);\n\n return () => {\n window.removeEventListener('resize', place);\n window.removeEventListener('scroll', place, true);\n hide();\n };\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n","import { hasDom } from './env';\n\ntype Targets =\n | readonly (Element | null | undefined)[]\n | (() => readonly (Element | null | undefined)[]);\n\n/**\n * Call `callback` when a pointer goes down outside all of `elements` (checked\n * in the capture phase, before the page reacts). Returns a cleanup function.\n */\nexport function onOutsidePointer(\n elements: Targets,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n const target = event.target;\n const list = typeof elements === 'function' ? elements() : elements;\n const inside =\n target instanceof Node &&\n list.some((element) => element?.contains(target) ?? false);\n if (!inside) {\n callback(event);\n }\n };\n document.addEventListener('pointerdown', listener, true);\n\n return () => {\n document.removeEventListener('pointerdown', listener, true);\n };\n}\n\n/**\n * Call `callback` when the pointer goes down on a panel's backdrop (the dimmed\n * area around a bottom sheet). A top-layer panel receives those events itself,\n * so they are told apart by position: on the panel, but outside its box.\n */\nexport function onBackdropPointer(\n panel: HTMLElement,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n if (event.target !== panel) {\n return;\n }\n const box = panel.getBoundingClientRect();\n const outside =\n event.clientX < box.left ||\n event.clientX > box.right ||\n event.clientY < box.top ||\n event.clientY > box.bottom;\n if (outside) {\n callback(event);\n }\n };\n panel.addEventListener('pointerdown', listener);\n\n return () => {\n panel.removeEventListener('pointerdown', listener);\n };\n}\n\n/**\n * Call `callback` on Escape. By default it listens in the capture phase and\n * stops the event, so an enclosing dialog does not close as well.\n */\nexport function onEscape(\n callback: (event: KeyboardEvent) => void,\n options: { readonly capture?: boolean; readonly stop?: boolean } = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const capture = options.capture ?? true;\n const listener = (event: KeyboardEvent): void => {\n if (event.key !== 'Escape') {\n return;\n }\n if (options.stop ?? true) {\n event.stopPropagation();\n event.preventDefault();\n }\n callback(event);\n };\n window.addEventListener('keydown', listener, capture);\n\n return () => {\n window.removeEventListener('keydown', listener, capture);\n };\n}\n\n/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */\nexport function focusFirst(\n container: ParentNode | null,\n selector = '[tabindex=\"0\"]',\n): boolean {\n const element = container?.querySelector<HTMLElement>(selector);\n if (!element) {\n return false;\n }\n element.focus();\n\n return true;\n}\n\n/** Put focus back, e.g. on the input after the panel closes. */\nexport function restoreFocus(element: HTMLElement | null | undefined): void {\n element?.focus({ preventScroll: true });\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n"],"mappings":"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"}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//#region src/env.d.ts
|
|
2
|
+
/** True in a browser-like environment; false during server rendering. */
|
|
3
|
+
export declare function hasDom(): boolean;
|
|
4
|
+
/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */
|
|
5
|
+
export declare function supportsPopover(): boolean;
|
|
6
|
+
/** Whether the user asked the system for less motion. */
|
|
7
|
+
export declare function prefersReducedMotion(): boolean;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/position.d.ts
|
|
10
|
+
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
|
11
|
+
/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */
|
|
12
|
+
type PanelLayout = "auto" | "popover" | "sheet";
|
|
13
|
+
interface PlaceOptions {
|
|
14
|
+
/** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */
|
|
15
|
+
readonly gap?: number | undefined;
|
|
16
|
+
/** Panel width in px; shrinks to fit narrow screens. Default 320. */
|
|
17
|
+
readonly width?: number | undefined;
|
|
18
|
+
/** Preferred side; flips when there is not enough room. Default 'bottom-start'. */
|
|
19
|
+
readonly placement?: Placement | undefined;
|
|
20
|
+
/** Default 'auto'. */
|
|
21
|
+
readonly layout?: PanelLayout | undefined;
|
|
22
|
+
/** 'auto' uses the sheet below this viewport width, in px. Default 480. */
|
|
23
|
+
readonly sheetBelow?: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
/** The layout to use for a viewport width. Pure. */
|
|
26
|
+
export declare function resolveLayout(layout: PanelLayout | undefined, viewportWidth: number, sheetBelow?: number): "popover" | "sheet";
|
|
27
|
+
interface Rect {
|
|
28
|
+
readonly top: number;
|
|
29
|
+
readonly left: number;
|
|
30
|
+
readonly bottom: number;
|
|
31
|
+
readonly right: number;
|
|
32
|
+
}
|
|
33
|
+
interface Position {
|
|
34
|
+
readonly top: number;
|
|
35
|
+
readonly left: number;
|
|
36
|
+
readonly width: number;
|
|
37
|
+
/** Where the panel ended up after flipping. */
|
|
38
|
+
readonly side: "top" | "bottom";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Where to put a panel next to an anchor, in viewport (position: fixed)
|
|
42
|
+
* coordinates. Pure, so it can be tested without a browser. Rules from the
|
|
43
|
+
* institute-SaaS picker: prefer the requested side, flip when the panel does
|
|
44
|
+
* not fit but does fit on the other side, and keep `gap` px from every edge
|
|
45
|
+
* (the panel may overlap the anchor rather than leave the screen).
|
|
46
|
+
*/
|
|
47
|
+
export declare function computePosition(anchor: Rect, panelHeight: number, viewport: {
|
|
48
|
+
readonly width: number;
|
|
49
|
+
readonly height: number;
|
|
50
|
+
}, options?: PlaceOptions): Position;
|
|
51
|
+
/**
|
|
52
|
+
* Show `panel` beside `anchor` once. Uses the browser's top layer when it can
|
|
53
|
+
* (so dialogs and scrolling containers never clip the panel), otherwise
|
|
54
|
+
* position: fixed. Returns a function that hides the panel again.
|
|
55
|
+
*/
|
|
56
|
+
export declare function placePopover(anchor: Element, panel: HTMLElement, options?: PlaceOptions): () => void;
|
|
57
|
+
/**
|
|
58
|
+
* Show the panel and keep it beside the anchor while the window resizes or
|
|
59
|
+
* any ancestor scrolls. Returns a function that stops tracking and hides it.
|
|
60
|
+
*/
|
|
61
|
+
export declare function trackPosition(anchor: Element, panel: HTMLElement, options?: PlaceOptions): () => void;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/events.d.ts
|
|
64
|
+
type Targets = readonly (Element | null | undefined)[] | (() => readonly (Element | null | undefined)[]);
|
|
65
|
+
/**
|
|
66
|
+
* Call `callback` when a pointer goes down outside all of `elements` (checked
|
|
67
|
+
* in the capture phase, before the page reacts). Returns a cleanup function.
|
|
68
|
+
*/
|
|
69
|
+
export declare function onOutsidePointer(elements: Targets, callback: (event: PointerEvent) => void): () => void;
|
|
70
|
+
/**
|
|
71
|
+
* Call `callback` when the pointer goes down on a panel's backdrop (the dimmed
|
|
72
|
+
* area around a bottom sheet). A top-layer panel receives those events itself,
|
|
73
|
+
* so they are told apart by position: on the panel, but outside its box.
|
|
74
|
+
*/
|
|
75
|
+
export declare function onBackdropPointer(panel: HTMLElement, callback: (event: PointerEvent) => void): () => void;
|
|
76
|
+
/**
|
|
77
|
+
* Call `callback` on Escape. By default it listens in the capture phase and
|
|
78
|
+
* stops the event, so an enclosing dialog does not close as well.
|
|
79
|
+
*/
|
|
80
|
+
export declare function onEscape(callback: (event: KeyboardEvent) => void, options?: {
|
|
81
|
+
readonly capture?: boolean;
|
|
82
|
+
readonly stop?: boolean;
|
|
83
|
+
}): () => void;
|
|
84
|
+
/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */
|
|
85
|
+
export declare function focusFirst(container: ParentNode | null, selector?: string): boolean;
|
|
86
|
+
/** Put focus back, e.g. on the input after the panel closes. */
|
|
87
|
+
export declare function restoreFocus(element: HTMLElement | null | undefined): void;
|
|
88
|
+
//#endregion
|
|
89
|
+
export type { PanelLayout, PlaceOptions, Placement, Position, Rect };
|
|
90
|
+
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//#region src/env.d.ts
|
|
2
|
+
/** True in a browser-like environment; false during server rendering. */
|
|
3
|
+
export declare function hasDom(): boolean;
|
|
4
|
+
/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */
|
|
5
|
+
export declare function supportsPopover(): boolean;
|
|
6
|
+
/** Whether the user asked the system for less motion. */
|
|
7
|
+
export declare function prefersReducedMotion(): boolean;
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/position.d.ts
|
|
10
|
+
type Placement = "bottom-start" | "bottom-end" | "top-start" | "top-end";
|
|
11
|
+
/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */
|
|
12
|
+
type PanelLayout = "auto" | "popover" | "sheet";
|
|
13
|
+
interface PlaceOptions {
|
|
14
|
+
/** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */
|
|
15
|
+
readonly gap?: number | undefined;
|
|
16
|
+
/** Panel width in px; shrinks to fit narrow screens. Default 320. */
|
|
17
|
+
readonly width?: number | undefined;
|
|
18
|
+
/** Preferred side; flips when there is not enough room. Default 'bottom-start'. */
|
|
19
|
+
readonly placement?: Placement | undefined;
|
|
20
|
+
/** Default 'auto'. */
|
|
21
|
+
readonly layout?: PanelLayout | undefined;
|
|
22
|
+
/** 'auto' uses the sheet below this viewport width, in px. Default 480. */
|
|
23
|
+
readonly sheetBelow?: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
/** The layout to use for a viewport width. Pure. */
|
|
26
|
+
export declare function resolveLayout(layout: PanelLayout | undefined, viewportWidth: number, sheetBelow?: number): "popover" | "sheet";
|
|
27
|
+
interface Rect {
|
|
28
|
+
readonly top: number;
|
|
29
|
+
readonly left: number;
|
|
30
|
+
readonly bottom: number;
|
|
31
|
+
readonly right: number;
|
|
32
|
+
}
|
|
33
|
+
interface Position {
|
|
34
|
+
readonly top: number;
|
|
35
|
+
readonly left: number;
|
|
36
|
+
readonly width: number;
|
|
37
|
+
/** Where the panel ended up after flipping. */
|
|
38
|
+
readonly side: "top" | "bottom";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Where to put a panel next to an anchor, in viewport (position: fixed)
|
|
42
|
+
* coordinates. Pure, so it can be tested without a browser. Rules from the
|
|
43
|
+
* institute-SaaS picker: prefer the requested side, flip when the panel does
|
|
44
|
+
* not fit but does fit on the other side, and keep `gap` px from every edge
|
|
45
|
+
* (the panel may overlap the anchor rather than leave the screen).
|
|
46
|
+
*/
|
|
47
|
+
export declare function computePosition(anchor: Rect, panelHeight: number, viewport: {
|
|
48
|
+
readonly width: number;
|
|
49
|
+
readonly height: number;
|
|
50
|
+
}, options?: PlaceOptions): Position;
|
|
51
|
+
/**
|
|
52
|
+
* Show `panel` beside `anchor` once. Uses the browser's top layer when it can
|
|
53
|
+
* (so dialogs and scrolling containers never clip the panel), otherwise
|
|
54
|
+
* position: fixed. Returns a function that hides the panel again.
|
|
55
|
+
*/
|
|
56
|
+
export declare function placePopover(anchor: Element, panel: HTMLElement, options?: PlaceOptions): () => void;
|
|
57
|
+
/**
|
|
58
|
+
* Show the panel and keep it beside the anchor while the window resizes or
|
|
59
|
+
* any ancestor scrolls. Returns a function that stops tracking and hides it.
|
|
60
|
+
*/
|
|
61
|
+
export declare function trackPosition(anchor: Element, panel: HTMLElement, options?: PlaceOptions): () => void;
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/events.d.ts
|
|
64
|
+
type Targets = readonly (Element | null | undefined)[] | (() => readonly (Element | null | undefined)[]);
|
|
65
|
+
/**
|
|
66
|
+
* Call `callback` when a pointer goes down outside all of `elements` (checked
|
|
67
|
+
* in the capture phase, before the page reacts). Returns a cleanup function.
|
|
68
|
+
*/
|
|
69
|
+
export declare function onOutsidePointer(elements: Targets, callback: (event: PointerEvent) => void): () => void;
|
|
70
|
+
/**
|
|
71
|
+
* Call `callback` when the pointer goes down on a panel's backdrop (the dimmed
|
|
72
|
+
* area around a bottom sheet). A top-layer panel receives those events itself,
|
|
73
|
+
* so they are told apart by position: on the panel, but outside its box.
|
|
74
|
+
*/
|
|
75
|
+
export declare function onBackdropPointer(panel: HTMLElement, callback: (event: PointerEvent) => void): () => void;
|
|
76
|
+
/**
|
|
77
|
+
* Call `callback` on Escape. By default it listens in the capture phase and
|
|
78
|
+
* stops the event, so an enclosing dialog does not close as well.
|
|
79
|
+
*/
|
|
80
|
+
export declare function onEscape(callback: (event: KeyboardEvent) => void, options?: {
|
|
81
|
+
readonly capture?: boolean;
|
|
82
|
+
readonly stop?: boolean;
|
|
83
|
+
}): () => void;
|
|
84
|
+
/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */
|
|
85
|
+
export declare function focusFirst(container: ParentNode | null, selector?: string): boolean;
|
|
86
|
+
/** Put focus back, e.g. on the input after the panel closes. */
|
|
87
|
+
export declare function restoreFocus(element: HTMLElement | null | undefined): void;
|
|
88
|
+
//#endregion
|
|
89
|
+
export type { PanelLayout, PlaceOptions, Placement, Position, Rect };
|
|
90
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
//#region src/env.ts
|
|
2
|
+
/** True in a browser-like environment; false during server rendering. */
|
|
3
|
+
function hasDom() {
|
|
4
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
5
|
+
}
|
|
6
|
+
/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */
|
|
7
|
+
function supportsPopover() {
|
|
8
|
+
return hasDom() && typeof HTMLElement !== "undefined" && typeof HTMLElement.prototype.showPopover === "function";
|
|
9
|
+
}
|
|
10
|
+
/** Whether the user asked the system for less motion. */
|
|
11
|
+
function prefersReducedMotion() {
|
|
12
|
+
return hasDom() && typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
13
|
+
}
|
|
14
|
+
//#endregion
|
|
15
|
+
//#region src/position.ts
|
|
16
|
+
/** The layout to use for a viewport width. Pure. */
|
|
17
|
+
function resolveLayout(layout, viewportWidth, sheetBelow = 480) {
|
|
18
|
+
if (layout !== void 0 && layout !== "auto") return layout;
|
|
19
|
+
return viewportWidth < sheetBelow ? "sheet" : "popover";
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Where to put a panel next to an anchor, in viewport (position: fixed)
|
|
23
|
+
* coordinates. Pure, so it can be tested without a browser. Rules from the
|
|
24
|
+
* institute-SaaS picker: prefer the requested side, flip when the panel does
|
|
25
|
+
* not fit but does fit on the other side, and keep `gap` px from every edge
|
|
26
|
+
* (the panel may overlap the anchor rather than leave the screen).
|
|
27
|
+
*/
|
|
28
|
+
function computePosition(anchor, panelHeight, viewport, options = {}) {
|
|
29
|
+
const gap = options.gap ?? 6;
|
|
30
|
+
const placement = options.placement ?? "bottom-start";
|
|
31
|
+
const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));
|
|
32
|
+
const roomBelow = viewport.height - anchor.bottom - gap * 2;
|
|
33
|
+
const roomAbove = anchor.top - gap * 2;
|
|
34
|
+
const side = placement.startsWith("top") ? roomAbove >= panelHeight || roomAbove >= roomBelow ? "top" : "bottom" : roomBelow >= panelHeight || roomBelow >= roomAbove ? "bottom" : "top";
|
|
35
|
+
const rawTop = side === "bottom" ? anchor.bottom + gap : anchor.top - gap - panelHeight;
|
|
36
|
+
const rawLeft = placement.endsWith("end") ? anchor.right - width : anchor.left;
|
|
37
|
+
return {
|
|
38
|
+
top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),
|
|
39
|
+
left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),
|
|
40
|
+
width,
|
|
41
|
+
side
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Show `panel` beside `anchor` once. Uses the browser's top layer when it can
|
|
46
|
+
* (so dialogs and scrolling containers never clip the panel), otherwise
|
|
47
|
+
* position: fixed. Returns a function that hides the panel again.
|
|
48
|
+
*/
|
|
49
|
+
function placePopover(anchor, panel, options = {}) {
|
|
50
|
+
if (!hasDom()) return noop$1;
|
|
51
|
+
const topLayer = supportsPopover();
|
|
52
|
+
if (topLayer) {
|
|
53
|
+
if (!panel.hasAttribute("popover")) panel.setAttribute("popover", "manual");
|
|
54
|
+
if (!panel.matches(":popover-open")) panel.showPopover();
|
|
55
|
+
}
|
|
56
|
+
const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);
|
|
57
|
+
panel.dataset["layout"] = layout;
|
|
58
|
+
if (layout === "sheet") {
|
|
59
|
+
Object.assign(panel.style, {
|
|
60
|
+
top: "",
|
|
61
|
+
left: "",
|
|
62
|
+
width: "",
|
|
63
|
+
position: "fixed",
|
|
64
|
+
margin: "0",
|
|
65
|
+
inset: "auto 0 0 0"
|
|
66
|
+
});
|
|
67
|
+
delete panel.dataset["side"];
|
|
68
|
+
return hider(panel, topLayer);
|
|
69
|
+
}
|
|
70
|
+
const position = computePosition(anchor.getBoundingClientRect(), panel.offsetHeight, {
|
|
71
|
+
width: window.innerWidth,
|
|
72
|
+
height: window.innerHeight
|
|
73
|
+
}, options);
|
|
74
|
+
Object.assign(panel.style, {
|
|
75
|
+
position: "fixed",
|
|
76
|
+
margin: "0",
|
|
77
|
+
inset: "auto",
|
|
78
|
+
top: `${position.top}px`,
|
|
79
|
+
left: `${position.left}px`,
|
|
80
|
+
width: `${position.width}px`
|
|
81
|
+
});
|
|
82
|
+
panel.dataset["side"] = position.side;
|
|
83
|
+
return hider(panel, topLayer);
|
|
84
|
+
}
|
|
85
|
+
function hider(panel, topLayer) {
|
|
86
|
+
return () => {
|
|
87
|
+
if (topLayer && panel.matches(":popover-open")) panel.hidePopover();
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Show the panel and keep it beside the anchor while the window resizes or
|
|
92
|
+
* any ancestor scrolls. Returns a function that stops tracking and hides it.
|
|
93
|
+
*/
|
|
94
|
+
function trackPosition(anchor, panel, options = {}) {
|
|
95
|
+
if (!hasDom()) return noop$1;
|
|
96
|
+
let hide = placePopover(anchor, panel, options);
|
|
97
|
+
const place = () => {
|
|
98
|
+
hide = placePopover(anchor, panel, options);
|
|
99
|
+
};
|
|
100
|
+
window.addEventListener("resize", place);
|
|
101
|
+
window.addEventListener("scroll", place, true);
|
|
102
|
+
return () => {
|
|
103
|
+
window.removeEventListener("resize", place);
|
|
104
|
+
window.removeEventListener("scroll", place, true);
|
|
105
|
+
hide();
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function noop$1() {}
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/events.ts
|
|
111
|
+
/**
|
|
112
|
+
* Call `callback` when a pointer goes down outside all of `elements` (checked
|
|
113
|
+
* in the capture phase, before the page reacts). Returns a cleanup function.
|
|
114
|
+
*/
|
|
115
|
+
function onOutsidePointer(elements, callback) {
|
|
116
|
+
if (!hasDom()) return noop;
|
|
117
|
+
const listener = (event) => {
|
|
118
|
+
const target = event.target;
|
|
119
|
+
const list = typeof elements === "function" ? elements() : elements;
|
|
120
|
+
if (!(target instanceof Node && list.some((element) => element?.contains(target) ?? false))) callback(event);
|
|
121
|
+
};
|
|
122
|
+
document.addEventListener("pointerdown", listener, true);
|
|
123
|
+
return () => {
|
|
124
|
+
document.removeEventListener("pointerdown", listener, true);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Call `callback` when the pointer goes down on a panel's backdrop (the dimmed
|
|
129
|
+
* area around a bottom sheet). A top-layer panel receives those events itself,
|
|
130
|
+
* so they are told apart by position: on the panel, but outside its box.
|
|
131
|
+
*/
|
|
132
|
+
function onBackdropPointer(panel, callback) {
|
|
133
|
+
if (!hasDom()) return noop;
|
|
134
|
+
const listener = (event) => {
|
|
135
|
+
if (event.target !== panel) return;
|
|
136
|
+
const box = panel.getBoundingClientRect();
|
|
137
|
+
if (event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom) callback(event);
|
|
138
|
+
};
|
|
139
|
+
panel.addEventListener("pointerdown", listener);
|
|
140
|
+
return () => {
|
|
141
|
+
panel.removeEventListener("pointerdown", listener);
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Call `callback` on Escape. By default it listens in the capture phase and
|
|
146
|
+
* stops the event, so an enclosing dialog does not close as well.
|
|
147
|
+
*/
|
|
148
|
+
function onEscape(callback, options = {}) {
|
|
149
|
+
if (!hasDom()) return noop;
|
|
150
|
+
const capture = options.capture ?? true;
|
|
151
|
+
const listener = (event) => {
|
|
152
|
+
if (event.key !== "Escape") return;
|
|
153
|
+
if (options.stop ?? true) {
|
|
154
|
+
event.stopPropagation();
|
|
155
|
+
event.preventDefault();
|
|
156
|
+
}
|
|
157
|
+
callback(event);
|
|
158
|
+
};
|
|
159
|
+
window.addEventListener("keydown", listener, capture);
|
|
160
|
+
return () => {
|
|
161
|
+
window.removeEventListener("keydown", listener, capture);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */
|
|
165
|
+
function focusFirst(container, selector = "[tabindex=\"0\"]") {
|
|
166
|
+
const element = container?.querySelector(selector);
|
|
167
|
+
if (!element) return false;
|
|
168
|
+
element.focus();
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
/** Put focus back, e.g. on the input after the panel closes. */
|
|
172
|
+
function restoreFocus(element) {
|
|
173
|
+
element?.focus({ preventScroll: true });
|
|
174
|
+
}
|
|
175
|
+
function noop() {}
|
|
176
|
+
//#endregion
|
|
177
|
+
export { computePosition, focusFirst, hasDom, onBackdropPointer, onEscape, onOutsidePointer, placePopover, prefersReducedMotion, resolveLayout, restoreFocus, supportsPopover, trackPosition };
|
|
178
|
+
|
|
179
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["noop"],"sources":["../src/env.ts","../src/position.ts","../src/events.ts"],"sourcesContent":["/** True in a browser-like environment; false during server rendering. */\nexport function hasDom(): boolean {\n return typeof window !== 'undefined' && typeof document !== 'undefined';\n}\n\n/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */\nexport function supportsPopover(): boolean {\n return (\n hasDom() &&\n typeof HTMLElement !== 'undefined' &&\n typeof HTMLElement.prototype.showPopover === 'function'\n );\n}\n\n/** Whether the user asked the system for less motion. */\nexport function prefersReducedMotion(): boolean {\n return (\n hasDom() &&\n typeof window.matchMedia === 'function' &&\n window.matchMedia('(prefers-reduced-motion: reduce)').matches\n );\n}\n","import { hasDom, supportsPopover } from './env';\n\nexport type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';\n\n/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */\nexport type PanelLayout = 'auto' | 'popover' | 'sheet';\n\nexport interface PlaceOptions {\n /** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */\n readonly gap?: number | undefined;\n /** Panel width in px; shrinks to fit narrow screens. Default 320. */\n readonly width?: number | undefined;\n /** Preferred side; flips when there is not enough room. Default 'bottom-start'. */\n readonly placement?: Placement | undefined;\n /** Default 'auto'. */\n readonly layout?: PanelLayout | undefined;\n /** 'auto' uses the sheet below this viewport width, in px. Default 480. */\n readonly sheetBelow?: number | undefined;\n}\n\n/** The layout to use for a viewport width. Pure. */\nexport function resolveLayout(\n layout: PanelLayout | undefined,\n viewportWidth: number,\n sheetBelow = 480,\n): 'popover' | 'sheet' {\n if (layout !== undefined && layout !== 'auto') {\n return layout;\n }\n\n return viewportWidth < sheetBelow ? 'sheet' : 'popover';\n}\n\nexport interface Rect {\n readonly top: number;\n readonly left: number;\n readonly bottom: number;\n readonly right: number;\n}\n\nexport interface Position {\n readonly top: number;\n readonly left: number;\n readonly width: number;\n /** Where the panel ended up after flipping. */\n readonly side: 'top' | 'bottom';\n}\n\n/**\n * Where to put a panel next to an anchor, in viewport (position: fixed)\n * coordinates. Pure, so it can be tested without a browser. Rules from the\n * institute-SaaS picker: prefer the requested side, flip when the panel does\n * not fit but does fit on the other side, and keep `gap` px from every edge\n * (the panel may overlap the anchor rather than leave the screen).\n */\nexport function computePosition(\n anchor: Rect,\n panelHeight: number,\n viewport: { readonly width: number; readonly height: number },\n options: PlaceOptions = {},\n): Position {\n const gap = options.gap ?? 6;\n const placement = options.placement ?? 'bottom-start';\n const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));\n const roomBelow = viewport.height - anchor.bottom - gap * 2;\n const roomAbove = anchor.top - gap * 2;\n const wantsTop = placement.startsWith('top');\n const side: 'top' | 'bottom' = wantsTop\n ? roomAbove >= panelHeight || roomAbove >= roomBelow\n ? 'top'\n : 'bottom'\n : roomBelow >= panelHeight || roomBelow >= roomAbove\n ? 'bottom'\n : 'top';\n const rawTop = side === 'bottom' ? anchor.bottom + gap : anchor.top - gap - panelHeight;\n const rawLeft = placement.endsWith('end') ? anchor.right - width : anchor.left;\n\n return {\n // Keep the whole panel on screen; when it is taller than the room, its top edge wins.\n top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),\n left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),\n width,\n side,\n };\n}\n\n/**\n * Show `panel` beside `anchor` once. Uses the browser's top layer when it can\n * (so dialogs and scrolling containers never clip the panel), otherwise\n * position: fixed. Returns a function that hides the panel again.\n */\nexport function placePopover(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const topLayer = supportsPopover();\n if (topLayer) {\n if (!panel.hasAttribute('popover')) {\n panel.setAttribute('popover', 'manual');\n }\n if (!panel.matches(':popover-open')) {\n panel.showPopover();\n }\n }\n const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);\n panel.dataset['layout'] = layout;\n if (layout === 'sheet') {\n // The stylesheet pins a sheet to the bottom edge; clear any popover coordinates.\n Object.assign(panel.style, {\n top: '',\n left: '',\n width: '',\n position: 'fixed',\n margin: '0',\n inset: 'auto 0 0 0',\n });\n delete panel.dataset['side'];\n\n return hider(panel, topLayer);\n }\n const position = computePosition(\n anchor.getBoundingClientRect(),\n panel.offsetHeight,\n { width: window.innerWidth, height: window.innerHeight },\n options,\n );\n Object.assign(panel.style, {\n position: 'fixed',\n margin: '0',\n inset: 'auto',\n top: `${position.top}px`,\n left: `${position.left}px`,\n width: `${position.width}px`,\n });\n panel.dataset['side'] = position.side;\n\n return hider(panel, topLayer);\n}\n\nfunction hider(panel: HTMLElement, topLayer: boolean): () => void {\n return () => {\n if (topLayer && panel.matches(':popover-open')) {\n panel.hidePopover();\n }\n };\n}\n\n/**\n * Show the panel and keep it beside the anchor while the window resizes or\n * any ancestor scrolls. Returns a function that stops tracking and hides it.\n */\nexport function trackPosition(\n anchor: Element,\n panel: HTMLElement,\n options: PlaceOptions = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n let hide = placePopover(anchor, panel, options);\n const place = (): void => {\n hide = placePopover(anchor, panel, options);\n };\n window.addEventListener('resize', place);\n window.addEventListener('scroll', place, true);\n\n return () => {\n window.removeEventListener('resize', place);\n window.removeEventListener('scroll', place, true);\n hide();\n };\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n","import { hasDom } from './env';\n\ntype Targets =\n | readonly (Element | null | undefined)[]\n | (() => readonly (Element | null | undefined)[]);\n\n/**\n * Call `callback` when a pointer goes down outside all of `elements` (checked\n * in the capture phase, before the page reacts). Returns a cleanup function.\n */\nexport function onOutsidePointer(\n elements: Targets,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n const target = event.target;\n const list = typeof elements === 'function' ? elements() : elements;\n const inside =\n target instanceof Node &&\n list.some((element) => element?.contains(target) ?? false);\n if (!inside) {\n callback(event);\n }\n };\n document.addEventListener('pointerdown', listener, true);\n\n return () => {\n document.removeEventListener('pointerdown', listener, true);\n };\n}\n\n/**\n * Call `callback` when the pointer goes down on a panel's backdrop (the dimmed\n * area around a bottom sheet). A top-layer panel receives those events itself,\n * so they are told apart by position: on the panel, but outside its box.\n */\nexport function onBackdropPointer(\n panel: HTMLElement,\n callback: (event: PointerEvent) => void,\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const listener = (event: PointerEvent): void => {\n if (event.target !== panel) {\n return;\n }\n const box = panel.getBoundingClientRect();\n const outside =\n event.clientX < box.left ||\n event.clientX > box.right ||\n event.clientY < box.top ||\n event.clientY > box.bottom;\n if (outside) {\n callback(event);\n }\n };\n panel.addEventListener('pointerdown', listener);\n\n return () => {\n panel.removeEventListener('pointerdown', listener);\n };\n}\n\n/**\n * Call `callback` on Escape. By default it listens in the capture phase and\n * stops the event, so an enclosing dialog does not close as well.\n */\nexport function onEscape(\n callback: (event: KeyboardEvent) => void,\n options: { readonly capture?: boolean; readonly stop?: boolean } = {},\n): () => void {\n if (!hasDom()) {\n return noop;\n }\n const capture = options.capture ?? true;\n const listener = (event: KeyboardEvent): void => {\n if (event.key !== 'Escape') {\n return;\n }\n if (options.stop ?? true) {\n event.stopPropagation();\n event.preventDefault();\n }\n callback(event);\n };\n window.addEventListener('keydown', listener, capture);\n\n return () => {\n window.removeEventListener('keydown', listener, capture);\n };\n}\n\n/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */\nexport function focusFirst(\n container: ParentNode | null,\n selector = '[tabindex=\"0\"]',\n): boolean {\n const element = container?.querySelector<HTMLElement>(selector);\n if (!element) {\n return false;\n }\n element.focus();\n\n return true;\n}\n\n/** Put focus back, e.g. on the input after the panel closes. */\nexport function restoreFocus(element: HTMLElement | null | undefined): void {\n element?.focus({ preventScroll: true });\n}\n\nfunction noop(): void {\n // Nothing to undo outside a browser.\n}\n"],"mappings":";;AACA,SAAgB,SAAkB;CAChC,OAAO,OAAO,WAAW,eAAe,OAAO,aAAa;AAC9D;;AAGA,SAAgB,kBAA2B;CACzC,OACE,OAAO,KACP,OAAO,gBAAgB,eACvB,OAAO,YAAY,UAAU,gBAAgB;AAEjD;;AAGA,SAAgB,uBAAgC;CAC9C,OACE,OAAO,KACP,OAAO,OAAO,eAAe,cAC7B,OAAO,WAAW,kCAAkC,CAAC,CAAC;AAE1D;;;;ACAA,SAAgB,cACd,QACA,eACA,aAAa,KACQ;CACrB,IAAI,WAAW,KAAA,KAAa,WAAW,QACrC,OAAO;CAGT,OAAO,gBAAgB,aAAa,UAAU;AAChD;;;;;;;;AAwBA,SAAgB,gBACd,QACA,aACA,UACA,UAAwB,CAAC,GACf;CACV,MAAM,MAAM,QAAQ,OAAO;CAC3B,MAAM,YAAY,QAAQ,aAAa;CACvC,MAAM,QAAQ,KAAK,IAAI,GAAG,KAAK,IAAI,QAAQ,SAAS,KAAK,SAAS,QAAQ,MAAM,CAAC,CAAC;CAClF,MAAM,YAAY,SAAS,SAAS,OAAO,SAAS,MAAM;CAC1D,MAAM,YAAY,OAAO,MAAM,MAAM;CAErC,MAAM,OADW,UAAU,WAAW,KACA,IAClC,aAAa,eAAe,aAAa,YACvC,QACA,WACF,aAAa,eAAe,aAAa,YACvC,WACA;CACN,MAAM,SAAS,SAAS,WAAW,OAAO,SAAS,MAAM,OAAO,MAAM,MAAM;CAC5E,MAAM,UAAU,UAAU,SAAS,KAAK,IAAI,OAAO,QAAQ,QAAQ,OAAO;CAE1E,OAAO;EAEL,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,QAAQ,SAAS,SAAS,MAAM,WAAW,CAAC;EACxE,MAAM,KAAK,IAAI,KAAK,KAAK,IAAI,SAAS,SAAS,QAAQ,QAAQ,GAAG,CAAC;EACnE;EACA;CACF;AACF;;;;;;AAOA,SAAgB,aACd,QACA,OACA,UAAwB,CAAC,GACb;CACZ,IAAI,CAAC,OAAO,GACV,OAAOA;CAET,MAAM,WAAW,gBAAgB;CACjC,IAAI,UAAU;EACZ,IAAI,CAAC,MAAM,aAAa,SAAS,GAC/B,MAAM,aAAa,WAAW,QAAQ;EAExC,IAAI,CAAC,MAAM,QAAQ,eAAe,GAChC,MAAM,YAAY;CAEtB;CACA,MAAM,SAAS,cAAc,QAAQ,QAAQ,OAAO,YAAY,QAAQ,UAAU;CAClF,MAAM,QAAQ,YAAY;CAC1B,IAAI,WAAW,SAAS;EAEtB,OAAO,OAAO,MAAM,OAAO;GACzB,KAAK;GACL,MAAM;GACN,OAAO;GACP,UAAU;GACV,QAAQ;GACR,OAAO;EACT,CAAC;EACD,OAAO,MAAM,QAAQ;EAErB,OAAO,MAAM,OAAO,QAAQ;CAC9B;CACA,MAAM,WAAW,gBACf,OAAO,sBAAsB,GAC7B,MAAM,cACN;EAAE,OAAO,OAAO;EAAY,QAAQ,OAAO;CAAY,GACvD,OACF;CACA,OAAO,OAAO,MAAM,OAAO;EACzB,UAAU;EACV,QAAQ;EACR,OAAO;EACP,KAAK,GAAG,SAAS,IAAI;EACrB,MAAM,GAAG,SAAS,KAAK;EACvB,OAAO,GAAG,SAAS,MAAM;CAC3B,CAAC;CACD,MAAM,QAAQ,UAAU,SAAS;CAEjC,OAAO,MAAM,OAAO,QAAQ;AAC9B;AAEA,SAAS,MAAM,OAAoB,UAA+B;CAChE,aAAa;EACX,IAAI,YAAY,MAAM,QAAQ,eAAe,GAC3C,MAAM,YAAY;CAEtB;AACF;;;;;AAMA,SAAgB,cACd,QACA,OACA,UAAwB,CAAC,GACb;CACZ,IAAI,CAAC,OAAO,GACV,OAAOA;CAET,IAAI,OAAO,aAAa,QAAQ,OAAO,OAAO;CAC9C,MAAM,cAAoB;EACxB,OAAO,aAAa,QAAQ,OAAO,OAAO;CAC5C;CACA,OAAO,iBAAiB,UAAU,KAAK;CACvC,OAAO,iBAAiB,UAAU,OAAO,IAAI;CAE7C,aAAa;EACX,OAAO,oBAAoB,UAAU,KAAK;EAC1C,OAAO,oBAAoB,UAAU,OAAO,IAAI;EAChD,KAAK;CACP;AACF;AAEA,SAASA,SAAa,CAEtB;;;;;;;ACzKA,SAAgB,iBACd,UACA,UACY;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,YAAY,UAA8B;EAC9C,MAAM,SAAS,MAAM;EACrB,MAAM,OAAO,OAAO,aAAa,aAAa,SAAS,IAAI;EAI3D,IAAI,EAFF,kBAAkB,QAClB,KAAK,MAAM,YAAY,SAAS,SAAS,MAAM,KAAK,KAAK,IAEzD,SAAS,KAAK;CAElB;CACA,SAAS,iBAAiB,eAAe,UAAU,IAAI;CAEvD,aAAa;EACX,SAAS,oBAAoB,eAAe,UAAU,IAAI;CAC5D;AACF;;;;;;AAOA,SAAgB,kBACd,OACA,UACY;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,YAAY,UAA8B;EAC9C,IAAI,MAAM,WAAW,OACnB;EAEF,MAAM,MAAM,MAAM,sBAAsB;EAMxC,IAJE,MAAM,UAAU,IAAI,QACpB,MAAM,UAAU,IAAI,SACpB,MAAM,UAAU,IAAI,OACpB,MAAM,UAAU,IAAI,QAEpB,SAAS,KAAK;CAElB;CACA,MAAM,iBAAiB,eAAe,QAAQ;CAE9C,aAAa;EACX,MAAM,oBAAoB,eAAe,QAAQ;CACnD;AACF;;;;;AAMA,SAAgB,SACd,UACA,UAAmE,CAAC,GACxD;CACZ,IAAI,CAAC,OAAO,GACV,OAAO;CAET,MAAM,UAAU,QAAQ,WAAW;CACnC,MAAM,YAAY,UAA+B;EAC/C,IAAI,MAAM,QAAQ,UAChB;EAEF,IAAI,QAAQ,QAAQ,MAAM;GACxB,MAAM,gBAAgB;GACtB,MAAM,eAAe;EACvB;EACA,SAAS,KAAK;CAChB;CACA,OAAO,iBAAiB,WAAW,UAAU,OAAO;CAEpD,aAAa;EACX,OAAO,oBAAoB,WAAW,UAAU,OAAO;CACzD;AACF;;AAGA,SAAgB,WACd,WACA,WAAW,oBACF;CACT,MAAM,UAAU,WAAW,cAA2B,QAAQ;CAC9D,IAAI,CAAC,SACH,OAAO;CAET,QAAQ,MAAM;CAEd,OAAO;AACT;;AAGA,SAAgB,aAAa,SAA+C;CAC1E,SAAS,MAAM,EAAE,eAAe,KAAK,CAAC;AACxC;AAEA,SAAS,OAAa,CAEtB"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nepdatejs/dom",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny browser helpers for @nepdatejs adapters: popover placement, outside click, Escape, focus.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nepali",
|
|
7
|
+
"bikram-sambat",
|
|
8
|
+
"bs",
|
|
9
|
+
"nepali-date",
|
|
10
|
+
"date",
|
|
11
|
+
"calendar",
|
|
12
|
+
"datepicker",
|
|
13
|
+
"nepal"
|
|
14
|
+
],
|
|
5
15
|
"license": "MIT",
|
|
16
|
+
"author": "Shishir Rai",
|
|
17
|
+
"homepage": "https://github.com/nepdate/nepdate/tree/main/packages/dom#readme",
|
|
6
18
|
"repository": {
|
|
7
19
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/nepdate/nepdate.git"
|
|
20
|
+
"url": "git+https://github.com/nepdate/nepdate.git",
|
|
21
|
+
"directory": "packages/dom"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/nepdate/nepdate/issues"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"@nepdatejs/source": "./src/index.ts",
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/index.d.cts",
|
|
37
|
+
"default": "./dist/index.cjs"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"./package.json": "./package.json"
|
|
41
|
+
},
|
|
42
|
+
"main": "./dist/index.cjs",
|
|
43
|
+
"module": "./dist/index.js",
|
|
44
|
+
"types": "./dist/index.d.cts",
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"src",
|
|
48
|
+
"!src/**/*.test.ts",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=18"
|
|
54
|
+
},
|
|
55
|
+
"publishConfig": {
|
|
56
|
+
"access": "public"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsdown",
|
|
60
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
61
|
+
"check:package": "publint --strict && attw --pack"
|
|
9
62
|
}
|
|
10
|
-
}
|
|
63
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** True in a browser-like environment; false during server rendering. */
|
|
2
|
+
export function hasDom(): boolean {
|
|
3
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Whether the browser can show elements in the top layer (`popover` + `showPopover`). */
|
|
7
|
+
export function supportsPopover(): boolean {
|
|
8
|
+
return (
|
|
9
|
+
hasDom() &&
|
|
10
|
+
typeof HTMLElement !== 'undefined' &&
|
|
11
|
+
typeof HTMLElement.prototype.showPopover === 'function'
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Whether the user asked the system for less motion. */
|
|
16
|
+
export function prefersReducedMotion(): boolean {
|
|
17
|
+
return (
|
|
18
|
+
hasDom() &&
|
|
19
|
+
typeof window.matchMedia === 'function' &&
|
|
20
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
21
|
+
);
|
|
22
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { hasDom } from './env';
|
|
2
|
+
|
|
3
|
+
type Targets =
|
|
4
|
+
| readonly (Element | null | undefined)[]
|
|
5
|
+
| (() => readonly (Element | null | undefined)[]);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Call `callback` when a pointer goes down outside all of `elements` (checked
|
|
9
|
+
* in the capture phase, before the page reacts). Returns a cleanup function.
|
|
10
|
+
*/
|
|
11
|
+
export function onOutsidePointer(
|
|
12
|
+
elements: Targets,
|
|
13
|
+
callback: (event: PointerEvent) => void,
|
|
14
|
+
): () => void {
|
|
15
|
+
if (!hasDom()) {
|
|
16
|
+
return noop;
|
|
17
|
+
}
|
|
18
|
+
const listener = (event: PointerEvent): void => {
|
|
19
|
+
const target = event.target;
|
|
20
|
+
const list = typeof elements === 'function' ? elements() : elements;
|
|
21
|
+
const inside =
|
|
22
|
+
target instanceof Node &&
|
|
23
|
+
list.some((element) => element?.contains(target) ?? false);
|
|
24
|
+
if (!inside) {
|
|
25
|
+
callback(event);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
document.addEventListener('pointerdown', listener, true);
|
|
29
|
+
|
|
30
|
+
return () => {
|
|
31
|
+
document.removeEventListener('pointerdown', listener, true);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Call `callback` when the pointer goes down on a panel's backdrop (the dimmed
|
|
37
|
+
* area around a bottom sheet). A top-layer panel receives those events itself,
|
|
38
|
+
* so they are told apart by position: on the panel, but outside its box.
|
|
39
|
+
*/
|
|
40
|
+
export function onBackdropPointer(
|
|
41
|
+
panel: HTMLElement,
|
|
42
|
+
callback: (event: PointerEvent) => void,
|
|
43
|
+
): () => void {
|
|
44
|
+
if (!hasDom()) {
|
|
45
|
+
return noop;
|
|
46
|
+
}
|
|
47
|
+
const listener = (event: PointerEvent): void => {
|
|
48
|
+
if (event.target !== panel) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const box = panel.getBoundingClientRect();
|
|
52
|
+
const outside =
|
|
53
|
+
event.clientX < box.left ||
|
|
54
|
+
event.clientX > box.right ||
|
|
55
|
+
event.clientY < box.top ||
|
|
56
|
+
event.clientY > box.bottom;
|
|
57
|
+
if (outside) {
|
|
58
|
+
callback(event);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
panel.addEventListener('pointerdown', listener);
|
|
62
|
+
|
|
63
|
+
return () => {
|
|
64
|
+
panel.removeEventListener('pointerdown', listener);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Call `callback` on Escape. By default it listens in the capture phase and
|
|
70
|
+
* stops the event, so an enclosing dialog does not close as well.
|
|
71
|
+
*/
|
|
72
|
+
export function onEscape(
|
|
73
|
+
callback: (event: KeyboardEvent) => void,
|
|
74
|
+
options: { readonly capture?: boolean; readonly stop?: boolean } = {},
|
|
75
|
+
): () => void {
|
|
76
|
+
if (!hasDom()) {
|
|
77
|
+
return noop;
|
|
78
|
+
}
|
|
79
|
+
const capture = options.capture ?? true;
|
|
80
|
+
const listener = (event: KeyboardEvent): void => {
|
|
81
|
+
if (event.key !== 'Escape') {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (options.stop ?? true) {
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
}
|
|
88
|
+
callback(event);
|
|
89
|
+
};
|
|
90
|
+
window.addEventListener('keydown', listener, capture);
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
window.removeEventListener('keydown', listener, capture);
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Focus the first element in `container` matching `selector` (the roving-tabindex day by default). */
|
|
98
|
+
export function focusFirst(
|
|
99
|
+
container: ParentNode | null,
|
|
100
|
+
selector = '[tabindex="0"]',
|
|
101
|
+
): boolean {
|
|
102
|
+
const element = container?.querySelector<HTMLElement>(selector);
|
|
103
|
+
if (!element) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
element.focus();
|
|
107
|
+
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Put focus back, e.g. on the input after the panel closes. */
|
|
112
|
+
export function restoreFocus(element: HTMLElement | null | undefined): void {
|
|
113
|
+
element?.focus({ preventScroll: true });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function noop(): void {
|
|
117
|
+
// Nothing to undo outside a browser.
|
|
118
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nepdatejs/dom: small browser helpers shared by the DOM adapters (vanilla,
|
|
3
|
+
* react, angular). Knows nothing about dates. Every helper is safe to call
|
|
4
|
+
* during server rendering and returns a cleanup function where it listens.
|
|
5
|
+
*/
|
|
6
|
+
export { hasDom, prefersReducedMotion, supportsPopover } from './env';
|
|
7
|
+
export {
|
|
8
|
+
computePosition,
|
|
9
|
+
placePopover,
|
|
10
|
+
resolveLayout,
|
|
11
|
+
type PanelLayout,
|
|
12
|
+
trackPosition,
|
|
13
|
+
type Placement,
|
|
14
|
+
type PlaceOptions,
|
|
15
|
+
type Position,
|
|
16
|
+
type Rect,
|
|
17
|
+
} from './position';
|
|
18
|
+
export {
|
|
19
|
+
focusFirst,
|
|
20
|
+
onBackdropPointer,
|
|
21
|
+
onEscape,
|
|
22
|
+
onOutsidePointer,
|
|
23
|
+
restoreFocus,
|
|
24
|
+
} from './events';
|
package/src/position.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { hasDom, supportsPopover } from './env';
|
|
2
|
+
|
|
3
|
+
export type Placement = 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
|
|
4
|
+
|
|
5
|
+
/** 'popover' sits beside the anchor; 'sheet' is a bottom sheet; 'auto' picks the sheet on narrow screens. */
|
|
6
|
+
export type PanelLayout = 'auto' | 'popover' | 'sheet';
|
|
7
|
+
|
|
8
|
+
export interface PlaceOptions {
|
|
9
|
+
/** Space between the anchor and the panel, and kept from the viewport edges. Default 6. */
|
|
10
|
+
readonly gap?: number | undefined;
|
|
11
|
+
/** Panel width in px; shrinks to fit narrow screens. Default 320. */
|
|
12
|
+
readonly width?: number | undefined;
|
|
13
|
+
/** Preferred side; flips when there is not enough room. Default 'bottom-start'. */
|
|
14
|
+
readonly placement?: Placement | undefined;
|
|
15
|
+
/** Default 'auto'. */
|
|
16
|
+
readonly layout?: PanelLayout | undefined;
|
|
17
|
+
/** 'auto' uses the sheet below this viewport width, in px. Default 480. */
|
|
18
|
+
readonly sheetBelow?: number | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The layout to use for a viewport width. Pure. */
|
|
22
|
+
export function resolveLayout(
|
|
23
|
+
layout: PanelLayout | undefined,
|
|
24
|
+
viewportWidth: number,
|
|
25
|
+
sheetBelow = 480,
|
|
26
|
+
): 'popover' | 'sheet' {
|
|
27
|
+
if (layout !== undefined && layout !== 'auto') {
|
|
28
|
+
return layout;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return viewportWidth < sheetBelow ? 'sheet' : 'popover';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Rect {
|
|
35
|
+
readonly top: number;
|
|
36
|
+
readonly left: number;
|
|
37
|
+
readonly bottom: number;
|
|
38
|
+
readonly right: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface Position {
|
|
42
|
+
readonly top: number;
|
|
43
|
+
readonly left: number;
|
|
44
|
+
readonly width: number;
|
|
45
|
+
/** Where the panel ended up after flipping. */
|
|
46
|
+
readonly side: 'top' | 'bottom';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Where to put a panel next to an anchor, in viewport (position: fixed)
|
|
51
|
+
* coordinates. Pure, so it can be tested without a browser. Rules from the
|
|
52
|
+
* institute-SaaS picker: prefer the requested side, flip when the panel does
|
|
53
|
+
* not fit but does fit on the other side, and keep `gap` px from every edge
|
|
54
|
+
* (the panel may overlap the anchor rather than leave the screen).
|
|
55
|
+
*/
|
|
56
|
+
export function computePosition(
|
|
57
|
+
anchor: Rect,
|
|
58
|
+
panelHeight: number,
|
|
59
|
+
viewport: { readonly width: number; readonly height: number },
|
|
60
|
+
options: PlaceOptions = {},
|
|
61
|
+
): Position {
|
|
62
|
+
const gap = options.gap ?? 6;
|
|
63
|
+
const placement = options.placement ?? 'bottom-start';
|
|
64
|
+
const width = Math.max(0, Math.min(options.width ?? 320, viewport.width - gap * 2));
|
|
65
|
+
const roomBelow = viewport.height - anchor.bottom - gap * 2;
|
|
66
|
+
const roomAbove = anchor.top - gap * 2;
|
|
67
|
+
const wantsTop = placement.startsWith('top');
|
|
68
|
+
const side: 'top' | 'bottom' = wantsTop
|
|
69
|
+
? roomAbove >= panelHeight || roomAbove >= roomBelow
|
|
70
|
+
? 'top'
|
|
71
|
+
: 'bottom'
|
|
72
|
+
: roomBelow >= panelHeight || roomBelow >= roomAbove
|
|
73
|
+
? 'bottom'
|
|
74
|
+
: 'top';
|
|
75
|
+
const rawTop = side === 'bottom' ? anchor.bottom + gap : anchor.top - gap - panelHeight;
|
|
76
|
+
const rawLeft = placement.endsWith('end') ? anchor.right - width : anchor.left;
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
// Keep the whole panel on screen; when it is taller than the room, its top edge wins.
|
|
80
|
+
top: Math.max(gap, Math.min(rawTop, viewport.height - gap - panelHeight)),
|
|
81
|
+
left: Math.max(gap, Math.min(rawLeft, viewport.width - width - gap)),
|
|
82
|
+
width,
|
|
83
|
+
side,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Show `panel` beside `anchor` once. Uses the browser's top layer when it can
|
|
89
|
+
* (so dialogs and scrolling containers never clip the panel), otherwise
|
|
90
|
+
* position: fixed. Returns a function that hides the panel again.
|
|
91
|
+
*/
|
|
92
|
+
export function placePopover(
|
|
93
|
+
anchor: Element,
|
|
94
|
+
panel: HTMLElement,
|
|
95
|
+
options: PlaceOptions = {},
|
|
96
|
+
): () => void {
|
|
97
|
+
if (!hasDom()) {
|
|
98
|
+
return noop;
|
|
99
|
+
}
|
|
100
|
+
const topLayer = supportsPopover();
|
|
101
|
+
if (topLayer) {
|
|
102
|
+
if (!panel.hasAttribute('popover')) {
|
|
103
|
+
panel.setAttribute('popover', 'manual');
|
|
104
|
+
}
|
|
105
|
+
if (!panel.matches(':popover-open')) {
|
|
106
|
+
panel.showPopover();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const layout = resolveLayout(options.layout, window.innerWidth, options.sheetBelow);
|
|
110
|
+
panel.dataset['layout'] = layout;
|
|
111
|
+
if (layout === 'sheet') {
|
|
112
|
+
// The stylesheet pins a sheet to the bottom edge; clear any popover coordinates.
|
|
113
|
+
Object.assign(panel.style, {
|
|
114
|
+
top: '',
|
|
115
|
+
left: '',
|
|
116
|
+
width: '',
|
|
117
|
+
position: 'fixed',
|
|
118
|
+
margin: '0',
|
|
119
|
+
inset: 'auto 0 0 0',
|
|
120
|
+
});
|
|
121
|
+
delete panel.dataset['side'];
|
|
122
|
+
|
|
123
|
+
return hider(panel, topLayer);
|
|
124
|
+
}
|
|
125
|
+
const position = computePosition(
|
|
126
|
+
anchor.getBoundingClientRect(),
|
|
127
|
+
panel.offsetHeight,
|
|
128
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
129
|
+
options,
|
|
130
|
+
);
|
|
131
|
+
Object.assign(panel.style, {
|
|
132
|
+
position: 'fixed',
|
|
133
|
+
margin: '0',
|
|
134
|
+
inset: 'auto',
|
|
135
|
+
top: `${position.top}px`,
|
|
136
|
+
left: `${position.left}px`,
|
|
137
|
+
width: `${position.width}px`,
|
|
138
|
+
});
|
|
139
|
+
panel.dataset['side'] = position.side;
|
|
140
|
+
|
|
141
|
+
return hider(panel, topLayer);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hider(panel: HTMLElement, topLayer: boolean): () => void {
|
|
145
|
+
return () => {
|
|
146
|
+
if (topLayer && panel.matches(':popover-open')) {
|
|
147
|
+
panel.hidePopover();
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Show the panel and keep it beside the anchor while the window resizes or
|
|
154
|
+
* any ancestor scrolls. Returns a function that stops tracking and hides it.
|
|
155
|
+
*/
|
|
156
|
+
export function trackPosition(
|
|
157
|
+
anchor: Element,
|
|
158
|
+
panel: HTMLElement,
|
|
159
|
+
options: PlaceOptions = {},
|
|
160
|
+
): () => void {
|
|
161
|
+
if (!hasDom()) {
|
|
162
|
+
return noop;
|
|
163
|
+
}
|
|
164
|
+
let hide = placePopover(anchor, panel, options);
|
|
165
|
+
const place = (): void => {
|
|
166
|
+
hide = placePopover(anchor, panel, options);
|
|
167
|
+
};
|
|
168
|
+
window.addEventListener('resize', place);
|
|
169
|
+
window.addEventListener('scroll', place, true);
|
|
170
|
+
|
|
171
|
+
return () => {
|
|
172
|
+
window.removeEventListener('resize', place);
|
|
173
|
+
window.removeEventListener('scroll', place, true);
|
|
174
|
+
hide();
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function noop(): void {
|
|
179
|
+
// Nothing to undo outside a browser.
|
|
180
|
+
}
|