Skip to content

Commit

Permalink
feat(async-stack): dispatch stackitemunmount and stackitemmount e…
Browse files Browse the repository at this point in the history
…vents in `stack.actions.render`
  • Loading branch information
vnphanquang committed Oct 25, 2024
1 parent 2b73c2b commit a1588b8
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 79 deletions.
5 changes: 5 additions & 0 deletions .changeset/friendly-days-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@svelte-put/async-stack': minor
---

add `stackitemunmount` and `stackitemunmount` events on nodes that used `stack.actions.render`
15 changes: 13 additions & 2 deletions packages/async-stack/src/stack.svelte.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mount } from 'svelte';
import { mount, tick } from 'svelte';

import { MissingComponentInCustomPush, NotFoundVariantConfig } from './errors.js';
import { StackItem } from './stack-item.svelte.js';
Expand Down Expand Up @@ -43,7 +43,18 @@ export class Stack {
},
intro: true,
});
return {};
tick().then(() => {
const detail = { item };
const event = new CustomEvent('stackitemmount', { detail });
node.dispatchEvent(event);
});
return {
destroy: () => {
const detail = { item };
const event = new CustomEvent('stackitemunmount', { detail });
node.dispatchEvent(event);
},
};
},
};

Expand Down
32 changes: 16 additions & 16 deletions packages/async-stack/src/types.package.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ import { ActionReturn } from 'svelte/action';
import type { StackItem } from './stack-item.svelte.js';
import type { ComponentResolved } from './types.public.js';

export type StackItemCommonConfig<
Variant extends string,
UserComponent extends Component<any>,
> = {
export type StackItemCommonConfig<Variant extends string, UserComponent extends Component<any>> = {
/**
* milliseconds to wait and automatically pop the stack item.
* Defaults to `0` (disabled)
Expand All @@ -24,9 +21,7 @@ export type StackItemCommonConfig<
id?:
| 'counter'
| 'uuid'
| ((
config: Required<Omit<StackItemInstanceConfig<Variant, UserComponent>, 'id'>>,
) => string);
| ((config: Required<Omit<StackItemInstanceConfig<Variant, UserComponent>, 'id'>>) => string);
};

/** predefined variant config provided while building a {@link Stack} instance */
Expand Down Expand Up @@ -56,23 +51,28 @@ export type StackItemState = 'idle' | 'elapsing' | 'paused' | 'timeout' | 'resol
export type StackItemByVariantPushConfig<
Variant extends string,
UserComponent extends Component<any>,
> = StackItemCommonConfig< Variant, UserComponent> & {
> = StackItemCommonConfig<Variant, UserComponent> & {
props?: Omit<ComponentProps<UserComponent>, 'item'>;
};

export type StackItemCustomPushConfig<
UserComponent extends Component<any>,
> = StackItemCommonConfig<'custom', UserComponent> & {
export type StackItemCustomPushConfig<UserComponent extends Component<any>> = StackItemCommonConfig<
'custom',
UserComponent
> & {
component: UserComponent;
props?: Omit<ComponentProps<UserComponent>, 'item'>;
};

export type StackItemPopVerboseInput <
UserComponent extends Component<any>,
>= {
export type StackItemPopVerboseInput<UserComponent extends Component<any>> = {
id?: string;
resolved?: ComponentResolved<UserComponent>;
};

export type StackItemRenderActionReturn = ActionReturn<StackItem<any>>;

export interface StackItemRenderActionAttributes {
onstackitemmount?: (event: CustomEvent<{ item: StackItem<any> }>) => void;
onstackitemunmount?: (event: CustomEvent<{ item: StackItem<any> }>) => void;
}
export type StackItemRenderActionReturn = ActionReturn<
StackItem<any>,
StackItemRenderActionAttributes
>;
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,11 @@ notiStack.resume(); // resuming all items

<h2 id="portal">Portal for Rendering Stack</h2>

As seen in [Comprehensive Example], `@svelte-put/async-stack` does not control how and where you render your stack items, as it cannot assume the styling and animation required for your project. To make it easier to set up one, however, you can utilize the `render` action on a `Stack` instance with the following pattern:
As seen in [Comprehensive Example], `@svelte-put/async-stack` does not control how and where you render your stack items, as it cannot assume the styling and animation required for your project. To make it easier to set up one, however, you can utilize the `render` action on a `Stack`.

### The helper `render` action

The `render` action makes sure your `StackItem` components receive all the correct props upon `push`. In the example above, the associated components will be rendered as direct children of `<div>`.

```svelte title=NotificationPortal.svelte
<script>
Expand All @@ -377,17 +381,33 @@ As seen in [Comprehensive Example], `@svelte-put/async-stack` does not control h

<!-- notification portal, typically setup at somewhere global like root layout -->
<aside>
<!-- :::focus -->
<ul>
<!-- :::focus -->
<!-- :::highlight -->
{#each notiStack.items as notification (notification.config.id)}
<li use:notiStack.actions.render={notification}></li>
{/each}
<!-- ::: -->
<!-- ::: -->
</ul>
</aside>
```

### Events upon Mounting & Unmounting `StackItem`

The `render` action also exposes two helpful events, `stackitemmount` and `stackitemunmount`, helpful for post processing and cleanup.

```svelte title="stackitemmount & stackitemunmount"
<li
use:notiStack.actions.render={notification}
<!-- :::highlight -->
{#each notiStack.items as notification (notification.config.id)}
<div use:notiStack.actions.render={notification}></div>
{/each}
<!-- ::: -->
onstackitemmount
onstackitemunmount
<!-- ::: -->
</aside>
></li>
```

The `render` action makes sure your `StackItem` components receive all the correct props upon `push`. In the example above, the associated components will be rendered as direct children of `<div>`.
For a more concrete example on how these events are helpful, see [Recipes - Svelte Sonner Inspired Toast System](#recipes-svelte-sonner).

<h2 id="component">Component for StackItem</h2>

Expand Down Expand Up @@ -550,7 +570,7 @@ The `ConfirmationModal` component uses a native `<dialog>` element, which is a g

</div>

### [Svelte Sonner](https://svelte-sonner.vercel.app/) Inspired Toast System
<h3 id="recipes-svelte-sonner"> <a href="https://svelte-sonner.vercel.app/">Svelte Sonner</a> Inspired Toast System </h3>

As demonstrated throughout this documentation, `svelte-put/async-stack` can be used to build a push notification system. See [Comprehensive Example] for a complete example. The example below shows a more fancy version that is inspired by [Svelte Sonner](https://svelte-sonner.vercel.app/).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,82 @@
const SCALE_STEP = 0.05;
const EXPAND_REM_GAP = 1;
const TRANSLATE_REM_STEP = 1;
/** if render top down, 1, if bottom up, -1 */
const Y_SIGN = -1;
let olElement: HTMLOListElement;
let expanded = $state(false);
let visibleItems = $derived(toastStack.items.slice(-3));
const olHeight = $derived.by(() => {
if (!expanded) return 'auto';
const contentPx = visibleItems.reduce(
(acc, item) => {
const el = olElement.querySelector(`[data-id="${item.config.id}"]`);
acc = acc + (el?.clientHeight ?? 0) + EXPAND_REM_GAP;
let liMap: Record<string, HTMLLIElement> = $state({});
let liHeightMap = $derived(
Object.entries(liMap).reduce(
(acc, [id, li]) => {
acc[id] = li.clientHeight;
return acc;
},
0,
);
const remGap = EXPAND_REM_GAP * (visibleItems.length - 1);
return `calc(${contentPx}px + ${remGap}rem)`;
});
{} as Record<string, number>,
),
);
let liOpacityMap = $derived(
Object.entries(liMap).reduce(
(acc, [id, li]) => {
const index = parseInt(li.dataset.index ?? '0') || 0;
acc[id] = index >= toastStack.items.length - MAX_ITEMS ? 1 : 0;
return acc;
},
{} as Record<string, number>,
),
);
let liTransformMap = $derived(
Object.entries(liMap).reduce(
(acc, [id, li]) => {
const index = parseInt(li.dataset.index ?? '0') || 0;
if (!expanded) {
const delta = Math.min(toastStack.items.length - index, MAX_ITEMS + 1) - 1;
const scaleX = 1 - delta * SCALE_STEP;
function getTransform(index: number) {
if (!expanded) {
const delta = Math.min(toastStack.items.length - index, MAX_ITEMS + 1) - 1;
const scale = 1 - delta * SCALE_STEP;
const translateY = -1 * delta * TRANSLATE_REM_STEP;
return `scale(${scale}) translateY(${translateY}rem)`;
}
const liHeight = liHeightMap[id] ?? 0;
let topLiHeight = 0;
const topItem = toastStack.items.at(-1);
if (topItem) topLiHeight = liHeightMap[topItem.config.id] ?? 0;
let accumulatedHeight = 0;
let rem = 0;
for (let i = toastStack.items.length - 1; i > index; i--) {
const notification = toastStack.items[i];
const el = olElement.querySelector(`[data-id="${notification.config.id}"]`);
accumulatedHeight += el?.clientHeight ?? 0;
rem += EXPAND_REM_GAP;
}
let scaleY = scaleX;
let yPx = 0;
if (liHeight >= topLiHeight) {
scaleY = topLiHeight / liHeight || 1;
} else {
yPx = Y_SIGN * (topLiHeight - liHeight * scaleY);
}
const yRem = (Y_SIGN * delta * TRANSLATE_REM_STEP) / scaleY;
let scale = index < toastStack.items.length - MAX_ITEMS ? 1 - MAX_ITEMS * SCALE_STEP : 1;
acc[id] = `scaleX(${scaleX}) scaleY(${scaleY}) translateY(calc(${yPx}px + ${yRem}rem))`;
return acc;
}
return `scale(${scale}) translateY(calc(-${accumulatedHeight}px - ${rem}rem))`;
}
let accumulatedHeight = 0;
let rem = 0;
for (let i = toastStack.items.length - 1; i > index; i--) {
accumulatedHeight += liHeightMap[toastStack.items[i].config.id] ?? 0;
rem += EXPAND_REM_GAP;
}
let scale = index < toastStack.items.length - MAX_ITEMS ? 1 - MAX_ITEMS * SCALE_STEP : 1;
function getOpacity(index: number) {
if (index >= toastStack.items.length - MAX_ITEMS) return 1;
return 0;
}
acc[id] =
`scale(${scale}) translateY(calc(${Y_SIGN * accumulatedHeight}px + ${Y_SIGN * rem}rem))`;
return acc;
},
{} as Record<string, string>,
),
);
const olHeight = $derived.by(() => {
if (!expanded) return 'auto';
const contentPx = visibleItems.reduce((acc, item) => {
acc += (liHeightMap[item.config.id] ?? 0) + EXPAND_REM_GAP;
return acc;
}, 0);
const remGap = EXPAND_REM_GAP * (visibleItems.length - 1);
return `calc(${contentPx}px + ${remGap}rem)`;
});
function onMouseEnter() {
expanded = true;
Expand All @@ -65,23 +97,28 @@

<!-- toast portal, typically setup at somewhere global like root layout -->
<ol
class="fixed bottom-2 right-4 z-notification grid content-end items-end tb:bottom-10 tb:right-10"
class="z-notification tb:bottom-10 tb:right-10 fixed bottom-2 right-4 grid content-end items-end"
style:height={olHeight}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
data-expanded={expanded}
bind:this={olElement}
>
{#each toastStack.items as notification, index (notification.config.id)}
{@const id = notification.config.id}
<li
data-id={id}
data-index={index}
class="w-full origin-center"
animate:flip={{ duration: 200 }}
transition:fly={{ duration: 200, y: '2rem' }}
style:opacity={getOpacity(index)}
style:transform={getTransform(index)}
style:opacity={liOpacityMap[id]}
style:transform={liTransformMap[id]}
use:toastStack.actions.render={notification}
onstackitemmount={(e) => {
liMap[id] = e.target as HTMLLIElement;
}}
onstackitemunmount={() => {
delete liMap[id];
}}
></li>
{/each}
</ol>
Expand All @@ -104,4 +141,4 @@
transition-duration: inherit;
transition-property: transform;
}
</style>
</style>
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@ import { stack } from '@svelte-put/async-stack';

import Toast from './Toast.svelte';

export const toastStack = stack({ timeout: 3000 }).addVariant('toast', Toast).build();
export const toastStack = stack({ timeout: 3_000 }).addVariant('toast', Toast).build();

// create push proxies
const STATUSES = ['info', 'success', 'warning', 'error'] as const;
type Status = typeof STATUSES[number];
type Status = (typeof STATUSES)[number];
type ToastPusher = (message: string) => void;
export const toast: Record<Status, ToastPusher> = STATUSES.reduce((acc, status) => {
acc[status] = (message) => toastStack.push('toast', {
props: {
status,
title: status.charAt(0).toUpperCase() + status.slice(1),
message,
},
});
return acc;
}, {} as Record<Status, ToastPusher>);
export const toast: Record<Status, ToastPusher> = STATUSES.reduce(
(acc, status) => {
acc[status] = (message) =>
toastStack.push('toast', {
props: {
status,
title: status.charAt(0).toUpperCase() + status.slice(1),
message,
},
});
return acc;
},
{} as Record<Status, ToastPusher>,
);

0 comments on commit a1588b8

Please sign in to comment.