Files
oikos/web/node_modules/svelte-sonner/dist/Toast.svelte
dtoro d4d99a7473
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 1 — extract the client (web SPA + desktop) to dtoro/oikos-web
Problem: the hexagonal refactor churns the backend tree for nine more
phases; the UI delivery stack (web/ SPA, cmd/desktop Wails wrapper,
compose/web image) must move to its own repo first so doc/layout
rewrites land once on a backend-only tree.

Change:
- New repo git.hubris.network/dtoro/oikos-web (v0.33.0): web/, desktop/
  (updateURL repointed to oikos-web releases), compose/, own CI (web +
  desktop jobs), own deploy script (CI-green gate, TOCTOU guard,
  version-tagged images, prune-to-3), own webhook receiver on :9798 +
  launchd unit, own compose project publishing the same 8091:80.
- Cutover executed on mac-mini in order: oikos stack's web service
  stopped+removed, oikos-web project brought up on 8091; outer Caddy
  untouched (targets the published port) — serving + Authentik flow +
  /wails 404 quirk verified post-cutover.
- Stripped from oikos: web/, cmd/desktop/, compose/web/, desktop CI
  workflow, ci.yml web job, Makefile ui/desktop/desktop-package/install
  targets, the compose web service, oikos-web from deploy.sh's fallback
  prune list; wails + go-keyring dropped from go.mod, vendor synced.
- README / CONTRIBUTING / AGENTS.md / .agents dev+operations docs now
  point at the new repo; mbse + mascot design docs carry a path note.

Risk: production SPA serving depends on the new pipeline now; rollback
is versioned-image re-up of the old web service from a pre-split
checkout (port 8091). Desktop builds installed before the split still
check dtoro/oikos releases — one manual reinstall, noted in the
oikos-web release notes.

Verification: go vet, make test (race), make generate-check, golangci
(no new findings; baseline down 400→365); post-cutover curls —
localhost:8091 200, /wails/runtime.js 404, outer Caddy 302 Authentik.
2026-08-15 22:27:52 +02:00

502 lines
16 KiB
Svelte

<script lang="ts" module>"use strict";
// Default lifetime of a toasts (in ms)
const TOAST_LIFETIME = 4000;
// Default gap between toasts
const GAP = 14;
// Threshold to dismiss a toast
const SWIPE_THRESHOLD = 45;
// Equal to exit animation duration
const TIME_BEFORE_UNMOUNT = 200;
const SCALE_MULTIPLIER = 0.05;
const DEFAULT_TOAST_CLASSES = {
toast: '',
title: '',
description: '',
loader: '',
closeButton: '',
cancelButton: '',
actionButton: '',
action: '',
warning: '',
error: '',
success: '',
default: '',
info: '',
loading: ''
};
function getDefaultSwipeDirections(position) {
const [y, x] = position.split('-');
const directions = [];
if (y) {
directions.push(y);
}
if (x) {
directions.push(x);
}
return directions;
}
function getDampening(delta) {
const factor = Math.abs(delta) / 20;
return 1 / (1.5 + factor);
}
</script>
<script lang="ts">import { onMount, untrack } from 'svelte';
import { isAction } from './types.js';
import { toastState } from './toast-state.svelte.js';
import { cn } from './internal/helpers.js';
import { useDocumentHidden } from './internal/use-document-hidden.svelte.js';
import Loader from './Loader.svelte';
let { toast, index, expanded, invert: invertFromToaster, position, visibleToasts, expandByDefault, closeButton: closeButtonFromToaster, interacting, cancelButtonStyle = '', actionButtonStyle = '', duration: durationFromToaster, descriptionClass = '', classes: classesProp, unstyled = false, loadingIcon, successIcon, errorIcon, warningIcon, closeIcon, infoIcon, defaultRichColors = false, swipeDirections: swipeDirectionsProp, closeButtonAriaLabel, pauseWhenPageIsHidden, ...restProps } = $props();
const defaultClasses = { ...DEFAULT_TOAST_CLASSES };
let mounted = $state(false);
let removed = $state(false);
let swiping = $state(false);
let swipeOut = $state(false);
let isSwiped = $state(false);
let offsetBeforeRemove = $state(0);
let initialHeight = $state(0);
let remainingTime = toast.duration || durationFromToaster || TOAST_LIFETIME;
let dragStartTime = $state(null);
let toastRef = $state();
let swipeDirection = $state(null);
let swipeOutDirection = $state(null);
const isFront = $derived(index === 0);
const isVisible = $derived(index + 1 <= visibleToasts);
const toastType = $derived(toast.type);
const dismissible = $derived(toast.dismissible !== undefined
? toast.dismissible !== false
: toast.dismissable !== false);
const toastClass = $derived(toast.class || '');
const toastDescriptionClass = $derived(toast.descriptionClass || '');
// height index is used to calculate the offset as it gets updated before the toast array, which means we can calculate the new layout faster.
const heightIndex = $derived(toastState.heights.findIndex((height) => height.toastId === toast.id) ||
0);
const closeButton = $derived(toast.closeButton ?? closeButtonFromToaster);
const duration = $derived(toast.duration ?? durationFromToaster ?? TOAST_LIFETIME);
let pointerStart = null;
const coords = $derived(position.split('-'));
const toastsHeightBefore = $derived(toastState.heights.reduce((prev, curr, reducerIndex) => {
if (reducerIndex >= heightIndex)
return prev;
return prev + curr.height;
}, 0));
const isDocumentHidden = useDocumentHidden();
const invert = $derived(toast.invert || invertFromToaster);
const disabled = $derived(toastType === 'loading');
const classes = $derived({ ...defaultClasses, ...classesProp });
const toastTitle = $derived(toast.title);
const toastDescription = $derived(toast.description);
let closeTimerStartTime = $state(0);
let lastCloseTimerStartTime = $state(0);
const offset = $derived(Math.round(heightIndex * GAP + toastsHeightBefore));
$effect(() => {
toastTitle;
toastDescription;
let scale;
if (expanded || expandByDefault) {
scale = 1;
}
else {
scale = 1 - index * SCALE_MULTIPLIER;
}
const toastEl = untrack(() => toastRef);
if (toastEl === undefined)
return;
toastEl.style.setProperty('height', 'auto');
const offsetHeight = toastEl.offsetHeight;
const rectHeight = toastEl.getBoundingClientRect().height;
const scaledRectHeight = Math.round((rectHeight / scale + Number.EPSILON) & 100) / 100;
toastEl.style.removeProperty('height');
let finalHeight;
if (Math.abs(scaledRectHeight - offsetHeight) < 1) {
// use scaledRectHeight as it's more precise
finalHeight = scaledRectHeight;
}
else {
// toast was transitioning its scale, so scaledRectHeight isn't accurate
finalHeight = offsetHeight;
}
initialHeight = finalHeight;
toastState.setHeight({ toastId: toast.id, height: finalHeight });
});
function deleteToast() {
removed = true;
// save the offset for the exit swipe animation
offsetBeforeRemove = offset;
toastState.removeHeight(toast.id);
setTimeout(() => {
toastState.remove(toast.id);
}, TIME_BEFORE_UNMOUNT);
}
let timeoutId;
const isPromiseLoadingOrInfiniteDuration = $derived((toast.promise && toastType === 'loading') ||
toast.duration === Number.POSITIVE_INFINITY);
function startTimer() {
closeTimerStartTime = new Date().getTime();
// let the toast know it has started
timeoutId = setTimeout(() => {
toast.onAutoClose?.(toast);
deleteToast();
}, remainingTime);
}
function pauseTimer() {
if (lastCloseTimerStartTime < closeTimerStartTime) {
// get the elapsed time since the timer started
const elapsedTime = new Date().getTime() - closeTimerStartTime;
remainingTime = remainingTime - elapsedTime;
}
lastCloseTimerStartTime = new Date().getTime();
}
$effect(() => {
if (toast.updated) {
// if the toast has been updated after the initial render,
// we want to reset the timer and set the remaining time to the
// new duration
clearTimeout(timeoutId);
remainingTime = duration;
if (!isPromiseLoadingOrInfiniteDuration) {
startTimer();
}
}
});
$effect(() => {
if (!isPromiseLoadingOrInfiniteDuration) {
if (expanded || interacting || (pauseWhenPageIsHidden && isDocumentHidden.current)) {
pauseTimer();
}
else {
startTimer();
}
}
return () => clearTimeout(timeoutId);
});
onMount(() => {
mounted = true;
const height = toastRef?.getBoundingClientRect().height;
initialHeight = height;
toastState.setHeight({ toastId: toast.id, height });
return () => {
toastState.removeHeight(toast.id);
};
});
$effect(() => {
if (toast.delete) {
untrack(() => {
deleteToast();
toast.onDismiss?.(toast);
});
}
});
const handlePointerDown = (event) => {
if (disabled)
return;
offsetBeforeRemove = offset;
const target = event.target;
// ensure we maintain correct pointer capture even when going outside of the toast (e.g. when swiping)
target.setPointerCapture(event.pointerId);
if (target.tagName === 'BUTTON')
return;
swiping = true;
pointerStart = { x: event.clientX, y: event.clientY };
};
const handlePointerUp = () => {
if (swipeOut || !dismissible)
return;
pointerStart = null;
const swipeAmountX = Number(toastRef?.style
.getPropertyValue('--swipe-amount-x')
.replace('px', '') || 0);
const swipeAmountY = Number(toastRef?.style
.getPropertyValue('--swipe-amount-y')
.replace('px', '') || 0);
const timeTaken = new Date().getTime() - (dragStartTime?.getTime() ?? 0);
const swipeAmount = swipeDirection === 'x' ? swipeAmountX : swipeAmountY;
const velocity = Math.abs(swipeAmount) / timeTaken;
// remove only if threshold is met
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
offsetBeforeRemove = offset;
toast.onDismiss?.(toast);
if (swipeDirection === 'x') {
swipeOutDirection = swipeAmountX > 0 ? 'right' : 'left';
}
else {
swipeOutDirection = swipeAmountY > 0 ? 'down' : 'up';
}
deleteToast();
swipeOut = true;
return;
}
else {
toastRef?.style.setProperty('--swipe-amount-x', '0px');
toastRef?.style.setProperty('--swipe-amount-y', '0px');
}
isSwiped = false;
swiping = false;
swipeDirection = null;
};
const handlePointerMove = (event) => {
if (!pointerStart || !dismissible)
return;
const isHighlighted = (window.getSelection()?.toString().length ?? -1) > 0;
if (isHighlighted)
return;
const yDelta = event.clientY - pointerStart.y;
const xDelta = event.clientX - pointerStart.x;
const swipeDirections = swipeDirectionsProp ?? getDefaultSwipeDirections(position);
// Determine swipe direction if not already locked
if (!swipeDirection && (Math.abs(xDelta) > 1 || Math.abs(yDelta) > 1)) {
swipeDirection = Math.abs(xDelta) > Math.abs(yDelta) ? 'x' : 'y';
}
let swipeAmount = { x: 0, y: 0 };
if (swipeDirection === 'y') {
// handle vertical swipes
if (swipeDirections.includes('top') ||
swipeDirections.includes('bottom')) {
if ((swipeDirections.includes('top') && yDelta < 0) ||
(swipeDirections.includes('bottom') && yDelta > 0)) {
swipeAmount.y = yDelta;
}
else {
// smoothly transition to dampened movement
const dampenedDelta = yDelta * getDampening(yDelta);
// ensure we don't jump when transition to dampened movement
swipeAmount.y =
Math.abs(dampenedDelta) < Math.abs(yDelta)
? dampenedDelta
: yDelta;
}
}
}
else if (swipeDirection === 'x') {
// handle horizontal swipes
if (swipeDirections.includes('left') ||
swipeDirections.includes('right')) {
if ((swipeDirections.includes('left') && xDelta < 0) ||
(swipeDirections.includes('right') && xDelta > 0)) {
swipeAmount.x = xDelta;
}
else {
// Smoothly transition to dampened movement
const dampenedDelta = xDelta * getDampening(xDelta);
// Ensure we don't jump when transitioning to dampened movement
swipeAmount.x =
Math.abs(dampenedDelta) < Math.abs(xDelta)
? dampenedDelta
: xDelta;
}
}
}
if (Math.abs(swipeAmount.x) > 0 || Math.abs(swipeAmount.y) > 0) {
isSwiped = true;
}
toastRef?.style.setProperty('--swipe-amount-x', `${swipeAmount.x}px`);
toastRef?.style.setProperty('--swipe-amount-y', `${swipeAmount.y}px`);
};
const handleDragEnd = () => {
swiping = false;
swipeDirection = null;
pointerStart = null;
};
const icon = $derived.by(() => {
if (toast.icon)
return toast.icon;
if (toastType === 'success')
return successIcon;
if (toastType === 'error')
return errorIcon;
if (toastType === 'warning')
return warningIcon;
if (toastType === 'info')
return infoIcon;
if (toastType === 'loading')
return loadingIcon;
return null;
});
</script>
{#snippet LoadingIcon()}
{#if loadingIcon}
<div
class={cn(classes?.loader, toast?.classes?.loader, 'sonner-loader')}
data-visible={toastType === 'loading'}
>
{@render loadingIcon()}
</div>
{:else}
<Loader
class={cn(classes?.loader, toast.classes?.loader)}
visible={toastType === 'loading'}
/>
{/if}
{/snippet}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<li
tabindex={0}
bind:this={toastRef}
class={cn(
restProps.class,
toastClass,
classes?.toast,
toast?.classes?.toast,
classes?.[toastType],
toast?.classes?.[toastType]
)}
aria-live={toast.important ? 'assertive' : 'polite'}
aria-atomic="true"
data-sonner-toast=""
data-rich-colors={toast.richColors ?? defaultRichColors}
data-styled={!(toast.component || toast.unstyled || unstyled)}
data-mounted={mounted}
data-promise={Boolean(toast.promise)}
data-swiped={isSwiped}
data-removed={removed}
data-visible={isVisible}
data-y-position={coords[0]}
data-x-position={coords[1]}
data-index={index}
data-front={isFront}
data-swiping={swiping}
data-dismissible={dismissible}
data-type={toastType}
data-invert={invert}
data-swipe-out={swipeOut}
data-swipe-direction={swipeOutDirection}
data-expanded={Boolean(expanded || (expandByDefault && mounted))}
style:--index={index}
style:--toasts-before={index}
style:--z-index={toastState.toasts.length - index}
style:--offset={`${removed ? offsetBeforeRemove : offset}px`}
style:--initial-height={expandByDefault ? 'auto' : `${initialHeight}px`}
style={`${restProps.style} ${toast.style}`}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointerdown={handlePointerDown}
ondragend={handleDragEnd}
>
{#if closeButton && !toast.component && toastType !== 'loading' && closeIcon !== null}
<button
aria-label={closeButtonAriaLabel}
data-disabled={disabled}
data-close-button
onclick={() => {
if (disabled || !dismissible) return;
deleteToast();
toast.onDismiss?.(toast);
}}
class={cn(classes?.closeButton, toast?.classes?.closeButton)}
>
{@render closeIcon?.()}
</button>
{/if}
{#if toast.component}
{@const Component = toast.component}
<Component {...toast.componentProps} closeToast={deleteToast} />
{:else}
{#if (toastType || toast.icon || toast.promise) && toast.icon !== null && (icon !== null || toast.icon)}
<div data-icon="" class={cn(classes?.icon, toast?.classes?.icon)}>
{#if toast.promise || toastType === 'loading'}
{#if toast.icon}
<toast.icon />
{:else}
{@render LoadingIcon()}
{/if}
{/if}
{#if toast.type !== 'loading'}
{#if toast.icon}
<toast.icon />
{:else if toastType === 'success'}
{@render successIcon?.()}
{:else if toastType === 'error'}
{@render errorIcon?.()}
{:else if toastType === 'warning'}
{@render warningIcon?.()}
{:else if toastType === 'info'}
{@render infoIcon?.()}
{/if}
{/if}
</div>
{/if}
<div data-content="" class={cn(classes?.content, toast?.classes?.content)}>
<div
data-title=""
class={cn(classes?.title, toast?.classes?.title)}
>
{#if toast.title}
{#if typeof toast.title !== 'string'}
{@const Title = toast.title}
<Title {...toast.componentProps} />
{:else}
{toast.title}
{/if}
{/if}
</div>
{#if toast.description}
<div
data-description=""
class={cn(
descriptionClass,
toastDescriptionClass,
classes?.description,
toast.classes?.description
)}
>
{#if typeof toast.description !== 'string'}
{@const Description = toast.description}
<Description {...toast.componentProps} />
{:else}
{toast.description}
{/if}
</div>
{/if}
</div>
{#if toast.cancel}
{#if typeof toast.cancel === 'function'}
<toast.cancel />
{:else if isAction(toast.cancel)}
<button
data-button
data-cancel
style={toast.cancelButtonStyle ?? cancelButtonStyle}
class={cn(
classes?.cancelButton,
toast?.classes?.cancelButton
)}
onclick={(event) => {
if (!isAction(toast.cancel)) return;
if (!dismissible) return;
toast.cancel?.onClick?.(event);
deleteToast();
}}
>
{toast.cancel.label}
</button>
{/if}
{/if}
{#if toast.action}
{#if typeof toast.action === 'function'}
<toast.action />
{:else if isAction(toast.action)}
<button
data-button=""
style={toast.actionButtonStyle ?? actionButtonStyle}
class={cn(
classes?.actionButton,
toast?.classes?.actionButton
)}
onclick={(event) => {
if (!isAction(toast.action)) return;
toast.action?.onClick(event);
if (event.defaultPrevented) return;
deleteToast();
}}
>
{toast.action.label}
</button>
{/if}
{/if}
{/if}
</li>