Skip to content

Commit

Permalink
feat(docs): add noti system. Show noti about migration to Svelte 5 on…
Browse files Browse the repository at this point in the history
… startup
  • Loading branch information
vnphanquang committed Oct 25, 2024
1 parent a1588b8 commit a408184
Show file tree
Hide file tree
Showing 12 changed files with 407 additions and 0 deletions.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions sites/docs/src/lib/notifications/components/BaseNotification.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<script lang="ts" module>
import type { StackItemProps } from '@svelte-put/async-stack';
import { inlineSvg } from '@svelte-put/inline-svg';
import type { HTMLAttributes } from 'svelte/elements';
export interface BaseNotificationProps extends HTMLAttributes<HTMLElement>, StackItemProps {
title?: string;
status?: 'info' | 'success' | 'warning' | 'error';
}
</script>

<script lang="ts">
import { getNotificationIcon } from '../notification.icon';
let {
item,
title,
status = 'info',
class: cls,
children,
...rest
}: BaseNotificationProps = $props();
const iconUrl = $derived(getNotificationIcon(status));
function dismiss() {
item.resolve();
}
</script>

<article
class="relative rounded border shadow {cls}"
role="status"
aria-live="polite"
aria-atomic="true"
data-status={status}
{...rest}
>
<div class="rounded-inherit relative flex items-start gap-3 overflow-hidden p-3">
{#await iconUrl}
<svg
class="h-6 w-6 shrink-0 animate-spin [animation-duration:1.5s]"
inline-src="phosphor/spinner-gap"
width="24"
height="24"
></svg>
{:then url}
<svg class="h-6 w-6 shrink-0" use:inlineSvg={url} width="24" height="24"></svg>
{/await}

<div class="w-full leading-normal">
<p class="mb-2 border-b border-current pb-1 font-medium">
{title ?? status[0].toUpperCase() + status.slice(1)}
</p>
{#if children}
{@render children()}
{/if}
</div>

<!-- progress, (time until auto-dismiss) -->
<div
class="progress absolute inset-x-0 bottom-0 h-0.5 origin-left overflow-hidden"
style:--progress-duration={item.config.timeout + 'ms'}
style:--progress-play-state={item.state === 'paused' ? 'paused' : 'running'}
aria-disabled="true"
></div>
</div>

<!-- x button to dismiss -->
<button
onclick={dismiss}
class="absolute right-0 top-0 -translate-y-1/2 translate-x-1/2 rounded-full border border-current bg-inherit p-1.5"
>
<svg class="h-3.5 w-3.5" inline-src="phosphor/x" width="14" height="14"></svg>
<span class="sr-only">Dismiss</span>
</button>
</article>

<style>
article {
color: var(--noti-color-fg);
background-color: var(--noti-color-bg);
&[data-status='info'] {
--noti-color-icon: theme('colors.info.element');
--noti-color-progress: theme('colors.info.surface.200');
--noti-color-bg: theme('colors.info.surface.DEFAULT');
--noti-color-fg: theme('colors.info.text');
}
&[data-status='success'] {
--noti-color-icon: theme('colors.success.element');
--noti-color-progress: theme('colors.success.surface.200');
--noti-color-bg: theme('colors.success.surface.DEFAULT');
--noti-color-fg: theme('colors.success.text');
}
&[data-status='warning'] {
--noti-color-icon: theme('colors.warning.element');
--noti-color-progress: theme('colors.warning.surface.200');
--noti-color-bg: theme('colors.warning.surface.DEFAULT');
--noti-color-fg: theme('colors.warning.text');
}
&[data-status='error'] {
--noti-color-icon: theme('colors.error.element');
--noti-color-progress: theme('colors.error.surface.200');
--noti-color-bg: theme('colors.error.surface.DEFAULT');
--noti-color-fg: theme('colors.error.text');
}
}
.progress {
background-color: var(--noti-color-progress);
animation: progress var(--progress-duration) linear;
animation-play-state: var(--progress-play-state);
}
svg {
color: var(--noti-color-icon);
}
@keyframes progress {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
</style>
145 changes: 145 additions & 0 deletions sites/docs/src/lib/notifications/components/NotificationPortal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<script lang="ts">
import { flip } from 'svelte/animate';
import { fly } from 'svelte/transition';
import { NotificationContext } from '../notification.context.svelte';
const MAX_ITEMS = 3;
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;
const { stack } = NotificationContext.get();
let expanded = $state(false);
let visibleItems = $derived(stack.items.slice(-3));
let liMap: Record<string, HTMLLIElement> = $state({});
let liHeightMap = $derived(
Object.entries(liMap).reduce(
(acc, [id, li]) => {
acc[id] = li.clientHeight;
return acc;
},
{} 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 >= stack.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(stack.items.length - index, MAX_ITEMS + 1) - 1;
const scaleX = 1 - delta * SCALE_STEP;
const liHeight = liHeightMap[id] ?? 0;
let topLiHeight = 0;
const topItem = stack.items.at(-1);
if (topItem) topLiHeight = liHeightMap[topItem.config.id] ?? 0;
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;
acc[id] = `scaleX(${scaleX}) scaleY(${scaleY}) translateY(calc(${yPx}px + ${yRem}rem))`;
return acc;
}
let accumulatedHeight = 0;
let rem = 0;
for (let i = stack.items.length - 1; i > index; i--) {
accumulatedHeight += liHeightMap[stack.items[i].config.id] ?? 0;
rem += EXPAND_REM_GAP;
}
let scale = index < stack.items.length - MAX_ITEMS ? 1 - MAX_ITEMS * SCALE_STEP : 1;
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;
stack.pause(); // pause all items;
}
function onMouseLeave() {
expanded = false;
stack.resume(); // resume all items;
}
</script>

<ol
class="z-notification tb:top-10 tb:right-10 fixed right-4 top-2 grid content-start items-start"
style:height={olHeight}
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
data-expanded={expanded}
>
{#each stack.items as notification, index (notification.config.id)}
{@const id = notification.config.id}
<li
data-index={index}
class="w-full origin-top"
animate:flip={{ duration: 200 }}
transition:fly={{ duration: 200, y: '-2rem' }}
style:opacity={liOpacityMap[id]}
style:transform={liTransformMap[id]}
use:stack.actions.render={notification}
onstackitemmount={(e) => {
liMap[id] = e.target as HTMLLIElement;
}}
onstackitemunmount={() => {
delete liMap[id];
}}
></li>
{/each}
</ol>

<style lang="postcss">
ol {
width: min(calc(100% - 2rem), 40rem);
transition-duration: 500ms;
&:hover {
transition-duration: 250ms;
}
}
li {
grid-column: 1;
grid-row: 1;
transition-timing-function: theme('transitionTimingFunction.DEFAULT');
transition-duration: inherit;
transition-property: transform;
}
</style>
34 changes: 34 additions & 0 deletions sites/docs/src/lib/notifications/components/Svelte5.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts" module>
import type { BaseNotificationProps } from './BaseNotification.svelte';
export interface ToastProps extends BaseNotificationProps {
message: string;
}
</script>

<script lang="ts">
import BaseNotification from './BaseNotification.svelte';
let { item, ...rest }: ToastProps = $props();
function doNotShowAgain() {
localStorage.setItem('skip-svelte-5-notification', 'true');
item.resolve();
}
</script>

<BaseNotification status="info" title="Are you runes-ed yet?" {item} {...rest}>
<div class="space-y-2">
<p>
The <code>svelte-put</code> collection has moved to
<a class="c-link" href="https://svelte.dev/docs/svelte/v5-migration-guide">Svelte 5</a>. I
recommend you do too. If you are still using Svelte 4, head over to
<a class="c-link" href="https://svelte-put-svelte-4.vnphanquang.com"
>the old documentation site</a
>.
</p>
<button class="c-btn c-btn--outlined text-fg ml-auto" onclick={doNotShowAgain}
>Don't show me again</button
>
</div>
</BaseNotification>
17 changes: 17 additions & 0 deletions sites/docs/src/lib/notifications/components/Toast.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts" module>
import type { BaseNotificationProps } from './BaseNotification.svelte';
export interface ToastProps extends BaseNotificationProps {
message: string;
}
</script>

<script lang="ts">
import BaseNotification from './BaseNotification.svelte';
let { message, ...rest }: ToastProps = $props();
</script>

<BaseNotification {...rest}>
<p>{message}</p>
</BaseNotification>
46 changes: 46 additions & 0 deletions sites/docs/src/lib/notifications/notification.context.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { stack as createStack } from '@svelte-put/async-stack';
import { getContext, setContext } from 'svelte';

import { STATUSES } from '$lib/constants';

import Toast from './components/Toast.svelte';

type PushToast = (message: string, title?: string) => void;

export function createNotificationStack() {
const stack = createStack({ timeout: 4_000 }).addVariant('toast', Toast).build();

const toaster = STATUSES.reduce(
(acc, status) => {
acc[status] = (message, title) =>
stack.push('toast', {
props: {
status,
title,
message,
},
});
return acc;
},
{} as Record<App.Status, PushToast>,
);

return {
stack,
toaster,
};
}

type NotificationContextValue = ReturnType<typeof createNotificationStack>;

export class NotificationContext {
static KEY = 'app:notification';

static set(init: NotificationContextValue = createNotificationStack()) {
return setContext(NotificationContext.KEY, init);
}

static get() {
return getContext<NotificationContextValue>(NotificationContext.KEY);
}
}
Loading

0 comments on commit a408184

Please sign in to comment.