Skip to content

Commit 7c60a7a

Browse files
committed
feat(async-stack): renderPopover helper
1 parent 31ee757 commit 7c60a7a

4 files changed

Lines changed: 190 additions & 0 deletions

File tree

.changeset/smooth-deers-swim.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@svelte-put/async-stack': minor
3+
---
4+
5+
(experimental) new `import('@svelte-put/async-stack/helpers').renderPopover` for rendering `StackItem` as popover (intended for notifications, toasts, etc.)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './enhance-dialog.js';
2+
export * from './render-popover.js';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Component } from 'svelte';
2+
import type { HTMLAttributes } from 'svelte/elements';
3+
4+
import type { StackItem } from '../stack-item.svelte';
5+
import type { StackItemProps } from '../types.public';
6+
7+
/**
8+
* Configuration options for `renderPopover`
9+
* Caution: changes are untracked, all options are expected
10+
* to be given at initialization time.
11+
*/
12+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
13+
export interface RenderPopoverOptions<Resolved> {
14+
/** find the top dialog */
15+
dialog?: () => HTMLDialogElement | null;
16+
/**
17+
* pause on `pointerenter`, `--play-state: paused`
18+
* resume on `pointerleave`, `--play-state: running`
19+
* @default true
20+
*/
21+
pauseOnPointerOver?: boolean;
22+
}
23+
24+
/**
25+
* NOTE: This is experimental API and may change in the future.
26+
*
27+
* render the StackItem as a popover. If there is an open dialog
28+
* (assuming in modal mode), move the popover inside the dialog,
29+
* to allow proper stacking and user interaction.
30+
*
31+
* set `--play-progress` and `--play-state` CSS custom properties
32+
* on the element, to allow CSS animation based on timeout progress.
33+
*
34+
* `stack.actions.render` is not necessary when already using this.
35+
*/
36+
export function renderPopover<Resolved>(
37+
item: StackItem<Component<StackItemProps<Resolved>>, Resolved>,
38+
options?: RenderPopoverOptions<Resolved>,
39+
): HTMLAttributes<HTMLElement>;
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { untrack, mount, unmount } from 'svelte';
2+
import { createAttachmentKey } from 'svelte/attachments';
3+
import { on } from 'svelte/events';
4+
5+
/**
6+
* @param {Element} node
7+
* @param {Element} parent
8+
*/
9+
function moveNodeIntoParent(node, parent) {
10+
if ('moveBefore' in parent) {
11+
// @ts-expect-error not yet in baseline
12+
parent.moveBefore(node, null);
13+
} else {
14+
parent.insertBefore(node, null);
15+
}
16+
}
17+
18+
/**
19+
* @param {HTMLElement} node
20+
* @param {import('../stack-item.svelte').StackItem<any>} item
21+
*/
22+
function setProgressAndSpeed(node, item) {
23+
if (!item.progress) return;
24+
node.style.setProperty('--play-progress', item.progress.toString());
25+
node.style.setProperty('--play-speed', (1 - item.progress) * item.config.timeout + 'ms');
26+
}
27+
28+
/**
29+
* @param {HTMLElement} node
30+
* @param {import('../stack-item.svelte').StackItem<any>} item
31+
*/
32+
function pause(node, item) {
33+
node.style.setProperty('--play-state', 'paused');
34+
item.pause();
35+
}
36+
37+
/**
38+
* @param {HTMLElement} node
39+
* @param {import('../stack-item.svelte').StackItem<any>} item
40+
*/
41+
function resume(node, item) {
42+
node.style.setProperty('--play-state', 'running');
43+
item.resume();
44+
}
45+
46+
/**
47+
* @param {HTMLElement} node
48+
* @param {import('../stack-item.svelte').StackItem<any>} item
49+
* @param {() => HTMLDialogElement | null} getDialog
50+
* @param {Element} [originalParent]
51+
* @returns {boolean}
52+
*/
53+
function moveIntoDialogIfAny(node, item, getDialog, originalParent) {
54+
if (!originalParent) {
55+
originalParent = node.parentElement ?? document.body;
56+
}
57+
58+
const dialog = getDialog();
59+
if (!dialog) return false;
60+
61+
// 1. move into dialog
62+
moveNodeIntoParent(node, dialog);
63+
64+
// 2. prevent clicks from propagating to the dialog backdrop
65+
const offStopPropagation = on(node, 'click', (e) => e.stopPropagation());
66+
67+
// 3. on dialog close, move to the next open dialog, if any
68+
// otherwise move back to original parent
69+
const offDialogClose = on(dialog, 'close', () => {
70+
pause(node, item);
71+
72+
if (!moveIntoDialogIfAny(node, item, getDialog, originalParent)) {
73+
moveNodeIntoParent(node, originalParent);
74+
}
75+
76+
setProgressAndSpeed(node, item);
77+
node.showPopover();
78+
resume(node, item);
79+
80+
offStopPropagation();
81+
offDialogClose();
82+
});
83+
84+
return true;
85+
}
86+
87+
/**
88+
* @returns {HTMLDialogElement | null}
89+
*/
90+
function findTopLevelDialog() {
91+
const el = Array.from(document.querySelectorAll('dialog:open')).pop();
92+
if (!el) return null;
93+
return /** @type {HTMLDialogElement} */ (el);
94+
}
95+
96+
/**
97+
* @template Resolved
98+
* @param {import('../stack-item.svelte').StackItem<import('svelte').Component<import('../types.public').StackItemProps<Resolved>>, Resolved>} item
99+
* @param {import('./render-popover').RenderPopoverOptions<Resolved>} [options]
100+
* @returns {import('svelte/elements').HTMLAttributes<HTMLElement>}
101+
*/
102+
export function renderPopover(item, options) {
103+
const getDialog = options?.dialog ?? findTopLevelDialog;
104+
const pauseOnPointerOver = options?.pauseOnPointerOver ?? true;
105+
106+
return {
107+
popover: 'manual',
108+
/**
109+
* @type {import('svelte/attachments').Attachment<HTMLElement>}
110+
*/
111+
[createAttachmentKey()]: function (node) {
112+
/** @type {(() => void)[]} */
113+
const offs = [];
114+
/** @type {ReturnType<typeof mount> | null} */
115+
let mounted = null;
116+
117+
untrack(() => {
118+
setProgressAndSpeed(node, item);
119+
pause(node, item);
120+
121+
mounted = mount(item.config.component, {
122+
target: node,
123+
props: { ...item.config.props, item },
124+
intro: true,
125+
});
126+
127+
// pause the timeout when hovering over the notification
128+
if (pauseOnPointerOver) {
129+
offs.push(on(node, 'pointerenter', () => pause(node, item)));
130+
offs.push(on(node, 'pointerleave', () => resume(node, item)));
131+
}
132+
133+
moveIntoDialogIfAny(node, item, getDialog);
134+
135+
node.showPopover();
136+
resume(node, item);
137+
});
138+
139+
return () => {
140+
offs.forEach((off) => off());
141+
if (mounted) unmount(mounted);
142+
};
143+
},
144+
};
145+
}

0 commit comments

Comments
 (0)