>): Array {
40 | if (points.length <= 1) {
41 | return points.slice();
42 | }
43 |
44 | // Andrew's monotone chain algorithm. Positive y coordinates correspond to "up"
45 | // as per the mathematical convention, instead of "down" as per the computer
46 | // graphics convention. This doesn't affect the correctness of the result.
47 |
48 | const upperHull: Array
= [];
49 | for (let i = 0; i < points.length; i++) {
50 | const p: P = points[i]!;
51 | while (upperHull.length >= 2) {
52 | const q: P = upperHull[upperHull.length - 1]!;
53 | const r: P = upperHull[upperHull.length - 2]!;
54 | if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) {
55 | upperHull.pop();
56 | } else {
57 | break;
58 | }
59 | }
60 | upperHull.push(p);
61 | }
62 | upperHull.pop();
63 |
64 | const lowerHull: Array
= [];
65 | for (let i = points.length - 1; i >= 0; i--) {
66 | const p: P = points[i]!;
67 | while (lowerHull.length >= 2) {
68 | const q: P = lowerHull[lowerHull.length - 1]!;
69 | const r: P = lowerHull[lowerHull.length - 2]!;
70 | if ((q.x - r.x) * (p.y - r.y) >= (q.y - r.y) * (p.x - r.x)) {
71 | lowerHull.pop();
72 | } else {
73 | break;
74 | }
75 | }
76 | lowerHull.push(p);
77 | }
78 | lowerHull.pop();
79 |
80 | if (
81 | upperHull.length === 1
82 | && lowerHull.length === 1
83 | && upperHull[0]!.x === lowerHull[0]!.x
84 | && upperHull[0]!.y === lowerHull[0]!.y
85 | ) {
86 | return upperHull;
87 | } else {
88 | return upperHull.concat(lowerHull);
89 | }
90 | }
91 |
92 | export function POINT_COMPARATOR(a: Point, b: Point): number {
93 | if (a.x < b.x) {
94 | return -1;
95 | } else if (a.x > b.x) {
96 | return +1;
97 | } else if (a.y < b.y) {
98 | return -1;
99 | } else if (a.y > b.y) {
100 | return +1;
101 | } else {
102 | return 0;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/polygon/index.ts:
--------------------------------------------------------------------------------
1 | import { type Point, type Polygon, makeHull } from "./hull.js";
2 |
3 | export * from "./hull.js";
4 |
5 | export function getPointsFromEl(el: HTMLElement): Array {
6 | const rect = el.getBoundingClientRect();
7 | return [
8 | { x: rect.left, y: rect.top },
9 | { x: rect.right, y: rect.top },
10 | { x: rect.right, y: rect.bottom },
11 | { x: rect.left, y: rect.bottom },
12 | ];
13 | }
14 |
15 | export function makeHullFromElements(els: Array): Array {
16 | const points = els.flatMap((el) => getPointsFromEl(el));
17 | return makeHull(points);
18 | }
19 |
20 | export function pointInPolygon(point: Point, polygon: Polygon) {
21 | let inside = false;
22 | for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
23 | const xi = polygon[i]!.x;
24 | const yi = polygon[i]!.y;
25 | const xj = polygon[j]!.x;
26 | const yj = polygon[j]!.y;
27 |
28 | const intersect
29 | = (yi > point.y) !== (yj > point.y) && point.x < ((xj - xi) * (point.y - yi)) / (yj - yi) + xi;
30 |
31 | if (intersect) {
32 | inside = !inside;
33 | }
34 | }
35 | return inside;
36 | }
37 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/portal.ts:
--------------------------------------------------------------------------------
1 | import type { PortalTarget } from "./use-portal.svelte.js";
2 | import { isHTMLElement } from "./is.js";
3 |
4 | /**
5 | * Get an element's ancestor which has a `data-portal` attribute.
6 | *
7 | * This is used to handle nested portals/overlays/dialogs/popovers.
8 | */
9 | function getPortalParent(node: HTMLElement) {
10 | let parent = node.parentElement;
11 | while (isHTMLElement(parent) && !parent.hasAttribute("data-portal")) {
12 | parent = parent.parentElement;
13 | }
14 |
15 | return parent || document.body;
16 | }
17 |
18 | export function getPortalDestination(node: HTMLElement, portalProp: PortalTarget) {
19 | if (portalProp !== undefined) {
20 | return portalProp;
21 | }
22 |
23 | return getPortalParent(node);
24 | }
25 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/scroll.ts:
--------------------------------------------------------------------------------
1 | // Modified from @zag-js/remove-scroll v0.10.2 (2023-06-10)
2 | // Source: https://github.com/chakra-ui/zag
3 | // https://github.com/chakra-ui/zag/blob/main/packages/utilities/remove-scroll/src/index.ts
4 |
5 | import { noop } from "./callbacks.js";
6 | import { isIos } from "./platform.js";
7 |
8 | const DATA_LOCK_ATTR = "data-melt-scroll-lock";
9 |
10 | function assignStyle(el: HTMLElement, style: Partial) {
11 | const previousStyle = el.style.cssText;
12 | Object.assign(el.style, style);
13 | return () => {
14 | el.style.cssText = previousStyle;
15 | };
16 | }
17 |
18 | function setCSSProperty(el: HTMLElement, property: string, value: string) {
19 | const previousValue = el.style.getPropertyValue(property);
20 | el.style.setProperty(property, value);
21 | return () => {
22 | if (previousValue) {
23 | el.style.setProperty(property, previousValue);
24 | } else {
25 | el.style.removeProperty(property);
26 | }
27 | };
28 | }
29 |
30 | function getPaddingProperty(documentElement: HTMLElement) {
31 | // RTL scrollbar
32 | const documentLeft = documentElement.getBoundingClientRect().left;
33 | const scrollbarX = Math.round(documentLeft) + documentElement.scrollLeft;
34 | return scrollbarX ? "paddingLeft" : "paddingRight";
35 | }
36 |
37 | export function removeScroll(): () => void {
38 | const win = document.defaultView ?? window;
39 | const { documentElement, body } = document;
40 |
41 | const locked = body.hasAttribute(DATA_LOCK_ATTR);
42 | if (locked) {
43 | return noop;
44 | }
45 |
46 | body.setAttribute(DATA_LOCK_ATTR, "");
47 |
48 | const scrollbarWidth = win.innerWidth - documentElement.clientWidth;
49 | const paddingProperty = getPaddingProperty(documentElement);
50 | const scrollbarSidePadding = win.getComputedStyle(body)[paddingProperty];
51 |
52 | const restoreScrollbarWidth = setCSSProperty(
53 | documentElement,
54 | "--scrollbar-width",
55 | `${scrollbarWidth}px`,
56 | );
57 |
58 | let restoreBodyStyle: () => void;
59 | if (isIos()) {
60 | // Only iOS doesn't respect `overflow: hidden` on document.body
61 | const { scrollX, scrollY, visualViewport } = win;
62 |
63 | // iOS 12 does not support `visualViewport`.
64 | const offsetLeft = visualViewport?.offsetLeft ?? 0;
65 | const offsetTop = visualViewport?.offsetTop ?? 0;
66 |
67 | const restoreStyle = assignStyle(body, {
68 | position: "fixed",
69 | overflow: "hidden",
70 | top: `${-(scrollY - Math.floor(offsetTop))}px`,
71 | left: `${-(scrollX - Math.floor(offsetLeft))}px`,
72 | right: "0",
73 | [paddingProperty]: `calc(${scrollbarSidePadding} + ${scrollbarWidth}px)`,
74 | });
75 |
76 | restoreBodyStyle = () => {
77 | restoreStyle();
78 | win.scrollTo(scrollX, scrollY);
79 | };
80 | } else {
81 | restoreBodyStyle = assignStyle(body, {
82 | overflow: "hidden",
83 | [paddingProperty]: `calc(${scrollbarSidePadding} + ${scrollbarWidth}px)`,
84 | });
85 | }
86 |
87 | return () => {
88 | restoreScrollbarWidth();
89 | restoreBodyStyle();
90 | body.removeAttribute(DATA_LOCK_ATTR);
91 | };
92 | }
93 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/start-stop.svelte.ts:
--------------------------------------------------------------------------------
1 | import { tick } from "svelte";
2 |
3 | export type StartNotifier = (set: (value: TValue) => void) => VoidFunction;
4 |
5 | export class StartStop {
6 | #value = $state() as TValue;
7 | #start: StartNotifier;
8 |
9 | constructor(initialValue: TValue, start: StartNotifier) {
10 | this.#value = initialValue;
11 | this.#start = start;
12 | }
13 |
14 | #subscribers = 0;
15 | #stop: VoidFunction | null = null;
16 |
17 | get value(): TValue {
18 | if ($effect.active()) {
19 | $effect(() => {
20 | this.#subscribers++;
21 | if (this.#subscribers === 1) {
22 | this.#subscribe();
23 | }
24 |
25 | return () => {
26 | tick().then(() => {
27 | this.#subscribers--;
28 | if (this.#subscribers === 0) {
29 | this.#unsubscribe();
30 | }
31 | });
32 | };
33 | });
34 | }
35 |
36 | return this.#value;
37 | }
38 |
39 | #subscribe() {
40 | this.#stop = this.#start((value) => {
41 | this.#value = value;
42 | });
43 | }
44 |
45 | #unsubscribe() {
46 | if (this.#stop === null) {
47 | return;
48 | }
49 |
50 | this.#stop();
51 | this.#stop = null;
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/style.ts:
--------------------------------------------------------------------------------
1 | import type { PropertiesHyphen as StyleObject } from "csstype";
2 |
3 | /**
4 | * A utility function that converts a style object to a string.
5 | *
6 | * @param style - The style object to convert
7 | * @returns The style object as a string
8 | */
9 | export function styleToString(style: StyleObject): string {
10 | return Object.keys(style).reduce((str, key) => {
11 | const value = style[key as keyof StyleObject];
12 | if (value === undefined) {
13 | return str;
14 | }
15 | return `${str}${key}:${value};`;
16 | }, "");
17 | }
18 |
19 | export type { StyleObject };
20 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/transition.ts:
--------------------------------------------------------------------------------
1 | import { addEventListener } from "./event.js";
2 |
3 | /**
4 | * Runs the given function once after the `outrostart` event is dispatched.
5 | *
6 | * @returns A function that removes the event listener from the target element.
7 | */
8 | export function afterTransitionOutStarts(target: EventTarget, fn: () => void) {
9 | return addEventListener(target, "outrostart", fn, { once: true });
10 | }
11 |
12 | /**
13 | * Runs the given function once after the `outroend` event is dispatched.
14 | *
15 | * @returns A function that removes the event listener from the target element.
16 | */
17 | export function afterTransitionOutEnds(target: EventTarget, fn: () => void) {
18 | return addEventListener(target, "outroend", fn, { once: true });
19 | }
20 |
21 | /**
22 | * Runs the given function once after the `outroend` event is dispatched,
23 | * or immediately if the element is not transitioning out.
24 | */
25 | export function runAfterTransitionOutOrImmediate(target: EventTarget, fn: () => void) {
26 | let transitioningOut = false;
27 |
28 | const cleanupListener = afterTransitionOutStarts(target, () => {
29 | transitioningOut = true;
30 | });
31 |
32 | // Wait for the animation to begin.
33 | requestAnimationFrame(() => {
34 | if (transitioningOut) {
35 | afterTransitionOutEnds(target, fn);
36 | } else {
37 | cleanupListener();
38 | fn();
39 | }
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/types.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable-next-line ts/ban-types
2 | export type Prettify = { [K in keyof T]: T[K] } & {};
3 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/use-escape-keydown.svelte.ts:
--------------------------------------------------------------------------------
1 | import { isFunction, isHTMLElement } from "./is.js";
2 | import { kbd } from "./keyboard.js";
3 | import { StartStop } from "./start-stop.svelte.js";
4 |
5 | export type EscapeKeydownConfig = {
6 | /**
7 | * Callback when user presses the escape key element.
8 | */
9 | handler: (event: KeyboardEvent) => void;
10 |
11 | /**
12 | * A predicate function or a list of elements that should not trigger the event.
13 | */
14 | ignore?: ((event: KeyboardEvent) => boolean) | Element[];
15 | };
16 |
17 | /**
18 | * Tracks the latest Escape keydown that occurred on the document.
19 | */
20 | const documentEscapeKeyDown = new StartStop(null, (set) => {
21 | function handleKeyDown(event: KeyboardEvent) {
22 | if (event.key === kbd.ESCAPE) {
23 | set(event);
24 | }
25 |
26 | // Prevent new subscribers from triggering immediately.
27 | set(null);
28 | }
29 |
30 | document.addEventListener("keydown", handleKeyDown, {
31 | passive: false,
32 | });
33 |
34 | return () => {
35 | document.removeEventListener("keydown", handleKeyDown);
36 | };
37 | });
38 |
39 | export function useEscapeKeydown(node: HTMLElement, config: EscapeKeydownConfig) {
40 | const { handler, ignore } = config;
41 |
42 | $effect(() => {
43 | const event = documentEscapeKeyDown.value;
44 | if (event === null) {
45 | return;
46 | }
47 |
48 | const target = event.target;
49 | if (!isHTMLElement(target) || target.closest("[data-escapee]") !== node) {
50 | return;
51 | }
52 |
53 | event.preventDefault();
54 |
55 | // If an ignore function is passed, check if it returns true
56 | if (isFunction(ignore) && ignore(event)) {
57 | return;
58 | }
59 |
60 | // If an ignore array is passed, check if any elements in the array match the target
61 | if (Array.isArray(ignore) && ignore.includes(target)) {
62 | return;
63 | }
64 |
65 | // If none of the above conditions are met, call the handler
66 | handler(event);
67 | });
68 |
69 | $effect(() => {
70 | node.dataset.escapee = "";
71 | return () => {
72 | delete node.dataset.escapee;
73 | };
74 | });
75 | }
76 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/use-event-listener.svelte.ts:
--------------------------------------------------------------------------------
1 | import { addEventListener } from "./event.js";
2 |
3 | export function useEventListener(
4 | target: Window,
5 | event: TEvent,
6 | handler: (this: Window, event: WindowEventMap[TEvent]) => unknown,
7 | options?: boolean | AddEventListenerOptions,
8 | ): void;
9 |
10 | export function useEventListener(
11 | target: Document,
12 | event: TEvent,
13 | handler: (this: Document, event: DocumentEventMap[TEvent]) => unknown,
14 | options?: boolean | AddEventListenerOptions,
15 | ): void;
16 |
17 | export function useEventListener<
18 | TElement extends HTMLElement,
19 | TEvent extends keyof HTMLElementEventMap,
20 | >(
21 | target: TElement,
22 | event: TEvent,
23 | handler: (this: TElement, event: HTMLElementEventMap[TEvent]) => unknown,
24 | options?: boolean | AddEventListenerOptions,
25 | ): void;
26 |
27 | export function useEventListener(
28 | target: EventTarget,
29 | event: string,
30 | handler: EventListenerOrEventListenerObject,
31 | options?: boolean | AddEventListenerOptions,
32 | ): void;
33 |
34 | export function useEventListener(
35 | target: EventTarget,
36 | event: string,
37 | listener: EventListenerOrEventListenerObject,
38 | options?: boolean | AddEventListenerOptions,
39 | ) {
40 | $effect(() => {
41 | return addEventListener(target, event, listener, options);
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/use-floating.svelte.ts:
--------------------------------------------------------------------------------
1 | // Modified from Grail UI v0.9.6 (2023-06-10)
2 | // Source: https://github.com/grail-ui/grail-ui
3 | // https://github.com/grail-ui/grail-ui/tree/master/packages/grail-ui/src/floating/placement.ts
4 | // https://github.com/grail-ui/grail-ui/tree/master/packages/grail-ui/src/floating/floating.types.ts
5 |
6 | import type { VirtualElement } from "@floating-ui/core";
7 | import {
8 | type Boundary,
9 | type Middleware,
10 | arrow,
11 | autoUpdate,
12 | computePosition,
13 | flip,
14 | offset,
15 | shift,
16 | size,
17 | } from "@floating-ui/dom";
18 | import { isHTMLElement } from "./is.js";
19 | import { noop } from "./callbacks.js";
20 |
21 | /**
22 | * The floating element configuration.
23 | * @see https://floating-ui.com/
24 | */
25 | export type FloatingConfig = {
26 | /**
27 | * The initial placement of the floating element.
28 | * @default "top"
29 | *
30 | * @see https://floating-ui.com/docs/computePosition#placement
31 | */
32 | placement?:
33 | | "top"
34 | | "top-start"
35 | | "top-end"
36 | | "right"
37 | | "right-start"
38 | | "right-end"
39 | | "bottom"
40 | | "bottom-start"
41 | | "bottom-end"
42 | | "left"
43 | | "left-start"
44 | | "left-end";
45 |
46 | /**
47 | * The strategy to use for positioning.
48 | * @default "absolute"
49 | *
50 | * @see https://floating-ui.com/docs/computePosition#placement
51 | */
52 | strategy?: "absolute" | "fixed";
53 |
54 | /**
55 | * The offset of the floating element.
56 | *
57 | * @see https://floating-ui.com/docs/offset#options
58 | */
59 | offset?: { mainAxis?: number; crossAxis?: number };
60 |
61 | /**
62 | * The main axis offset or gap between the reference and floating elements.
63 | * @default 5
64 | *
65 | * @see https://floating-ui.com/docs/offset#options
66 | */
67 | gutter?: number;
68 |
69 | /**
70 | * The virtual padding around the viewport edges to check for overflow.
71 | * @default 8
72 | *
73 | * @see https://floating-ui.com/docs/detectOverflow#padding
74 | */
75 | overflowPadding?: number;
76 |
77 | /**
78 | * Whether to flip the placement.
79 | * @default true
80 | *
81 | * @see https://floating-ui.com/docs/flip
82 | */
83 | flip?: boolean;
84 |
85 | /**
86 | * Whether the floating element can overlap the reference element.
87 | * @default false
88 | *
89 | * @see https://floating-ui.com/docs/shift#options
90 | */
91 | overlap?: boolean;
92 |
93 | /**
94 | * Whether to make the floating element same width as the reference element.
95 | * @default false
96 | *
97 | * @see https://floating-ui.com/docs/size
98 | */
99 | sameWidth?: boolean;
100 |
101 | /**
102 | * Whether the floating element should fit the viewport.
103 | * @default false
104 | *
105 | * @see https://floating-ui.com/docs/size
106 | */
107 | fitViewport?: boolean;
108 |
109 | /**
110 | * The overflow boundary of the reference element.
111 | *
112 | * @see https://floating-ui.com/docs/detectoverflow#boundary
113 | */
114 | boundary?: Boundary;
115 | };
116 |
117 | const ARROW_TRANSFORM = {
118 | bottom: "rotate(45deg)",
119 | left: "rotate(135deg)",
120 | top: "rotate(225deg)",
121 | right: "rotate(315deg)",
122 | };
123 |
124 | export function useFloating(
125 | reference: HTMLElement | VirtualElement,
126 | floating: HTMLElement,
127 | config: FloatingConfig | null = {},
128 | ) {
129 | if (config === null) {
130 | return { destroy: noop };
131 | }
132 |
133 | const {
134 | placement = "top",
135 | strategy = "absolute",
136 | offset: floatingOffset,
137 | gutter = 5,
138 | overflowPadding = 8,
139 | flip: flipPlacement = true,
140 | overlap = false,
141 | sameWidth = false,
142 | fitViewport = false,
143 | boundary,
144 | } = config;
145 |
146 | const arrowEl = floating.querySelector("[data-arrow=true]");
147 | const middleware: Middleware[] = [];
148 |
149 | if (flipPlacement) {
150 | middleware.push(
151 | flip({
152 | boundary,
153 | padding: overflowPadding,
154 | }),
155 | );
156 | }
157 |
158 | const arrowOffset = isHTMLElement(arrowEl) ? arrowEl.offsetHeight / 2 : 0;
159 | if (gutter || offset) {
160 | const data = gutter ? { mainAxis: gutter } : floatingOffset;
161 | if (data?.mainAxis != null) {
162 | data.mainAxis += arrowOffset;
163 | }
164 |
165 | middleware.push(offset(data));
166 | }
167 |
168 | middleware.push(
169 | shift({
170 | boundary,
171 | crossAxis: overlap,
172 | padding: overflowPadding,
173 | }),
174 | );
175 |
176 | if (arrowEl) {
177 | middleware.push(arrow({ element: arrowEl, padding: 8 }));
178 | }
179 |
180 | middleware.push(
181 | size({
182 | padding: overflowPadding,
183 | apply({ rects, availableHeight, availableWidth }) {
184 | if (sameWidth) {
185 | Object.assign(floating.style, {
186 | width: `${Math.round(rects.reference.width)}px`,
187 | minWidth: "unset",
188 | });
189 | }
190 |
191 | if (fitViewport) {
192 | Object.assign(floating.style, {
193 | maxWidth: `${availableWidth}px`,
194 | maxHeight: `${availableHeight}px`,
195 | });
196 | }
197 | },
198 | }),
199 | );
200 |
201 | function compute() {
202 | computePosition(reference, floating, {
203 | placement,
204 | middleware,
205 | strategy,
206 | }).then((data) => {
207 | const x = Math.round(data.x);
208 | const y = Math.round(data.y);
209 |
210 | Object.assign(floating.style, {
211 | position: strategy,
212 | top: `${y}px`,
213 | left: `${x}px`,
214 | });
215 |
216 | if (isHTMLElement(arrowEl) && data.middlewareData.arrow) {
217 | const { x, y } = data.middlewareData.arrow;
218 |
219 | const dir = data.placement.split("-")[0] as "top" | "bottom" | "left" | "right";
220 |
221 | Object.assign(arrowEl.style, {
222 | position: "absolute",
223 | left: x != null ? `${x}px` : "",
224 | top: y != null ? `${y}px` : "",
225 | [dir]: `calc(100% - ${arrowOffset}px)`,
226 | transform: ARROW_TRANSFORM[dir],
227 | backgroundColor: "inherit",
228 | zIndex: "inherit",
229 | });
230 | }
231 |
232 | return data;
233 | });
234 | }
235 |
236 | // Apply `position` to floating element prior to the computePosition() call.
237 | floating.style.position = strategy;
238 |
239 | const destroy = autoUpdate(reference, floating, compute);
240 | return { destroy };
241 | }
242 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/use-focus-trap.svelte.ts:
--------------------------------------------------------------------------------
1 | import { type Options as FocusTrapOptions, createFocusTrap } from "focus-trap";
2 |
3 | export function useFocusTrap(node: HTMLElement, options: FocusTrapOptions) {
4 | $effect(() => {
5 | const trap = createFocusTrap(node, options).activate();
6 | return () => {
7 | trap.deactivate();
8 | };
9 | });
10 | }
11 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/use-interact-outside.ts:
--------------------------------------------------------------------------------
1 | import { isElement } from "./is.js";
2 | import { useEventListener } from "./use-event-listener.svelte.js";
3 |
4 | export type InteractOutsideConfig = {
5 | /**
6 | * Callback fired when an outside `pointerup` event completes.
7 | */
8 | onInteractOutside?: (event: PointerEvent) => void;
9 |
10 | /**
11 | * Callback fired when an outside `pointerdown` event starts.
12 | *
13 | * This callback is useful when you want to know when the user
14 | * begins an outside interaction, but before the interaction
15 | * completes.
16 | */
17 | onInteractOutsideStart?: (event: PointerEvent) => void;
18 | };
19 |
20 | function isValidEvent(event: PointerEvent, node: HTMLElement): boolean {
21 | if (event.button > 0) {
22 | return false;
23 | }
24 |
25 | const target = event.target;
26 | if (!isElement(target)) {
27 | return false;
28 | }
29 |
30 | // if the target is no longer in the document (e.g. it was removed) ignore it
31 | const ownerDocument = target.ownerDocument;
32 | if (!ownerDocument || !ownerDocument.documentElement.contains(target)) {
33 | return false;
34 | }
35 |
36 | return !equalsOrContainsTarget(node, target);
37 | }
38 |
39 | function equalsOrContainsTarget(node: HTMLElement, target: Element) {
40 | return node === target || node.contains(target);
41 | }
42 |
43 | export function useInteractOutside(node: HTMLElement, config: InteractOutsideConfig) {
44 | const { onInteractOutside, onInteractOutsideStart } = config;
45 |
46 | let isPointerDown = false;
47 | let isPointerDownInside = false;
48 |
49 | function onPointerDown(event: PointerEvent) {
50 | if (onInteractOutside && isValidEvent(event, node)) {
51 | onInteractOutsideStart?.(event);
52 | }
53 |
54 | const target = event.target;
55 | if (isElement(target) && equalsOrContainsTarget(node, target)) {
56 | isPointerDownInside = true;
57 | }
58 |
59 | isPointerDown = true;
60 | }
61 |
62 | function onPointerUp(event: PointerEvent) {
63 | if (isPointerDown && !isPointerDownInside && isValidEvent(event, node)) {
64 | onInteractOutside?.(event);
65 | }
66 |
67 | isPointerDown = false;
68 | isPointerDownInside = false;
69 | }
70 |
71 | useEventListener(node.ownerDocument, "pointerdown", onPointerDown, true);
72 | useEventListener(node.ownerDocument, "pointerup", onPointerUp, true);
73 | }
74 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/use-modal.svelte.ts:
--------------------------------------------------------------------------------
1 | import { isElement } from "./is.js";
2 | import { useInteractOutside } from "./use-interact-outside.js";
3 |
4 | export type ModalConfig = {
5 | /**
6 | * Handler called when the overlay closes.
7 | */
8 | onClose?: () => void;
9 |
10 | /**
11 | * Whether the modal is able to be closed by interacting outside of it.
12 | * If true, the `onClose` callback will be called when the user interacts
13 | * outside of the modal.
14 | *
15 | * @default true
16 | */
17 | closeOnInteractOutside?: boolean;
18 |
19 | /**
20 | * If `closeOnInteractOutside` is `true` and this function is provided,
21 | * it will be called with the element that the outside interaction occurred
22 | * on. Whatever is returned from this function will determine whether the
23 | * modal actually closes or not.
24 | *
25 | * This is useful to filter out interactions with certain elements from
26 | * closing the modal. If `closeOnInteractOutside` is `false`, this function
27 | * will not be called.
28 | */
29 | shouldCloseOnInteractOutside?: (event: PointerEvent) => boolean;
30 | };
31 |
32 | const visibleModals: HTMLElement[] = [];
33 |
34 | function removeFromVisibleModals(node: HTMLElement) {
35 | const index = visibleModals.indexOf(node);
36 | if (index >= 0) {
37 | visibleModals.splice(index, 1);
38 | }
39 | }
40 |
41 | function isLastModal(node: HTMLElement) {
42 | return node === visibleModals.at(-1);
43 | }
44 |
45 | export function useModal(node: HTMLElement, config: ModalConfig) {
46 | const { onClose, closeOnInteractOutside = true, shouldCloseOnInteractOutside } = config;
47 |
48 | $effect(() => {
49 | visibleModals.push(node);
50 | return () => {
51 | removeFromVisibleModals(node);
52 | };
53 | });
54 |
55 | useInteractOutside(node, {
56 | onInteractOutsideStart(event) {
57 | const target = event.target;
58 | if (!isElement(target) || !isLastModal(node)) {
59 | return;
60 | }
61 |
62 | event.preventDefault();
63 | event.stopPropagation();
64 | event.stopImmediatePropagation();
65 | },
66 | onInteractOutside(event) {
67 | // We only want to call `onClose` if this is the topmost modal
68 | if (!closeOnInteractOutside || !isLastModal(node)) {
69 | return;
70 | }
71 |
72 | if (shouldCloseOnInteractOutside !== undefined && !shouldCloseOnInteractOutside(event)) {
73 | return;
74 | }
75 |
76 | event.preventDefault();
77 | event.stopPropagation();
78 | event.stopImmediatePropagation();
79 |
80 | if (onClose !== undefined) {
81 | onClose();
82 | visibleModals.pop();
83 | }
84 | },
85 | });
86 | }
87 |
--------------------------------------------------------------------------------
/packages/helpers/src/lib/use-portal.svelte.ts:
--------------------------------------------------------------------------------
1 | import { tick } from "svelte";
2 | import type { ActionReturn } from "svelte/action";
3 | import { isHTMLElement } from "./is.js";
4 |
5 | export type PortalTarget = string | HTMLElement | undefined;
6 |
7 | // TODO: use `$effect` to automatically cleanup
8 | export function usePortal(el: HTMLElement, target?: PortalTarget) {
9 | async function run() {
10 | const targetEl = await getTargetEl(target);
11 | targetEl.appendChild(el);
12 | el.dataset.portal = "";
13 | el.hidden = false;
14 | }
15 |
16 | run();
17 |
18 | return {
19 | update(newTarget) {
20 | target = newTarget;
21 | run();
22 | },
23 | destroy() {
24 | el.remove();
25 | },
26 | } satisfies ActionReturn;
27 | }
28 |
29 | async function getTargetEl(target: PortalTarget) {
30 | if (target === undefined) {
31 | return document.body;
32 | }
33 |
34 | if (isHTMLElement(target)) {
35 | return target;
36 | }
37 |
38 | let targetEl = document.querySelector(target);
39 | if (targetEl !== null) {
40 | return targetEl;
41 | }
42 |
43 | await tick();
44 |
45 | targetEl = document.querySelector(target);
46 | if (targetEl !== null) {
47 | return targetEl;
48 | }
49 |
50 | throw new Error(`No element found matching CSS selector: "${target}"`);
51 | }
52 |
--------------------------------------------------------------------------------
/packages/helpers/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from "@sveltejs/adapter-auto";
2 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | preprocess: vitePreprocess(),
7 | kit: {
8 | adapter: adapter(),
9 | },
10 | };
11 |
12 | export default config;
13 |
--------------------------------------------------------------------------------
/packages/helpers/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "module": "NodeNext",
5 | "moduleResolution": "NodeNext",
6 | "resolveJsonModule": true,
7 | "allowJs": true,
8 | "checkJs": true,
9 | "strict": true,
10 | "noUncheckedIndexedAccess": true,
11 | "sourceMap": true,
12 | "esModuleInterop": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "skipLibCheck": true
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/packages/helpers/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { svelte } from "@sveltejs/vite-plugin-svelte";
2 | import { defineConfig } from "vite";
3 |
4 | export default defineConfig({
5 | plugins: [svelte()],
6 | });
7 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - packages/**/*
3 | - docs
4 |
--------------------------------------------------------------------------------