-
-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(docs): add noti system. Show noti about migration to Svelte 5 on…
… startup
- Loading branch information
1 parent
a1588b8
commit a408184
Showing
12 changed files
with
407 additions
and
0 deletions.
There are no files selected for viewing
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.
1 change: 1 addition & 0 deletions
1
sites/docs/src/lib/assets/images/svg/phosphor/status/success.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions
1
sites/docs/src/lib/assets/images/svg/phosphor/status/warning.svg
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
131
sites/docs/src/lib/notifications/components/BaseNotification.svelte
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
145
sites/docs/src/lib/notifications/components/NotificationPortal.svelte
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
34
sites/docs/src/lib/notifications/components/Svelte5.svelte
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
46
sites/docs/src/lib/notifications/notification.context.svelte.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.