@nepdatejs/dom 0.0.0-stage → 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 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
- # Temporary Holding Version
1
+ # @nepdatejs/dom
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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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"}
@@ -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
@@ -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":"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"}
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@nepdatejs/dom",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ ],
15
+ "license": "MIT",
16
+ "author": "Shishir Rai",
17
+ "homepage": "https://github.com/nepdate/nepdate/tree/main/packages/dom#readme",
18
+ "repository": {
19
+ "type": "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"
62
+ }
6
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';
@@ -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
+ }