Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532<script module lang="ts"> import { getContext, setContext } from "svelte"; import { tv, type VariantProps } from "tailwind-variants";
export const button = tv({ base: [ "inline-flex cursor-pointer items-center justify-center overflow-hidden rounded-l-(--btn-radius-l) rounded-r-(--btn-radius-r) no-underline select-none [--btn-radius-l:var(--btn-radius)] [--btn-radius-r:var(--btn-radius)] [--btn-radius:var(--radius-sm)]", "transition-colors duration-150 ease-in-out", "hover:no-underline", "focus-visible:outline-2 focus-visible:outline-offset-2", "disabled:pointer-events-none aria-disabled:pointer-events-none" ], variants: { variant: { default: [ "border border-border-default bg-background-default text-foreground-default", "hover:bg-background-subtle aria-expanded:bg-background-subtle", "focus-visible:outline-border-focus", "disabled:border-transparent disabled:bg-background-subtle disabled:text-foreground-subtle", "aria-disabled:border-transparent aria-disabled:bg-background-subtle aria-disabled:text-foreground-subtle" ], primary: [ "border border-border-success bg-background-success-emphasis text-foreground-on-emphasis", "hover:bg-background-success-emphasis-hover aria-expanded:bg-background-success-emphasis-hover", "focus-visible:outline-background-success-emphasis", "disabled:border-transparent disabled:bg-background-success-subtle disabled:text-foreground-success-disabled", "aria-disabled:border-transparent aria-disabled:bg-background-success-subtle aria-disabled:text-foreground-success-disabled" ], ghost: [ "border border-transparent bg-transparent", "hover:bg-background-inset aria-expanded:bg-background-inset", "focus-visible:outline-border-focus", "disabled:text-foreground-disabled aria-disabled:text-foreground-disabled" ], danger: [ "border border-border-default bg-background-default text-foreground-danger", "hover:bg-background-subtle aria-expanded:bg-background-subtle", "focus-visible:outline-foreground-danger", "disabled:border-border-subtle disabled:text-foreground-danger-disabled aria-disabled:text-foreground-danger-disabled" ], success: [ "border border-border-default bg-background-default text-foreground-success", "hover:bg-background-subtle aria-expanded:bg-background-subtle", "focus-visible:outline-foreground-success", "disabled:border-border-subtle disabled:text-foreground-success-disabled aria-disabled:text-foreground-success-disabled" ], warning: [ "border border-border-default bg-background-default text-foreground-warning", "hover:bg-background-subtle aria-expanded:bg-background-subtle", "focus-visible:outline-foreground-warning", "disabled:border-border-subtle disabled:text-foreground-warning-disabled aria-disabled:text-foreground-warning-disabled" ] }, size: { mini: "min-h-6 min-w-xl gap-2xs px-2xs py-3xs typography-paragraph-mini", small: "min-h-7 gap-2xs px-2 py-3xs typography-paragraph-small", regular: "min-h-8 gap-1.5 px-sm py-3xs typography-paragraph-regular", large: "min-h-9 gap-xs px-sm py-3xs typography-paragraph-large" }, iconSide: { left: "", right: "", none: "" }, insetShadow: { true: "", false: "" } }, compoundVariants: [ { size: "mini", iconSide: "left", class: "pl-1" }, { size: "mini", iconSide: "right", class: "pr-1" }, { size: "small", iconSide: "left", class: "pl-1.75" }, { size: "small", iconSide: "right", class: "pr-1.75" }, { size: "regular", iconSide: "left", class: "pl-2.25" }, { size: "regular", iconSide: "right", class: "pr-2.25" }, { size: "large", iconSide: "left", class: "pl-2.75" }, { size: "large", iconSide: "right", class: "pr-2.75" }, { variant: ["default", "danger", "success", "warning"], insetShadow: true, class: [ "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)]", "hover:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-strong-hover)]", "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)]", "aria-expanded:*:translate-y-0.5 aria-expanded:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)] dark:aria-expanded:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)]", "disabled:before:shadow-none disabled:active:*:translate-y-0" ] }, { variant: "primary", insetShadow: true, class: [ "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-alpha-dark-25)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-alpha-light-25)]", "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-dark-50)] dark:active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-light-50)]", "aria-expanded:*:translate-y-0.5 aria-expanded:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-dark-50)] dark:aria-expanded:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-light-50)]", "disabled:before:shadow-none disabled:active:*:translate-y-0" ] } ], defaultVariants: { variant: "default", size: "regular", iconSide: "none", insetShadow: false } });
export const buttonIcon = tv({ base: "shrink-0", variants: { size: { mini: "size-3", small: "size-3.5", regular: "size-3.5", large: "size-4" } }, defaultVariants: { size: "regular" } });
export type ButtonVariants = VariantProps<typeof button>; export type ButtonIconVariants = VariantProps<typeof buttonIcon>;
const BUTTON_ICON_SIZE = Symbol("button-icon-size");
export const setButtonIconSize = (size: () => ButtonIconVariants["size"]) => setContext(BUTTON_ICON_SIZE, size);
export const getButtonIconSize = () => getContext<(() => ButtonIconVariants["size"]) | undefined>(BUTTON_ICON_SIZE);</script>
<script lang="ts"> import type { ResolvedPathname } from "$app/types"; import type { Component, Snippet } from "svelte"; import type { HTMLAnchorAttributes, HTMLButtonAttributes, SvelteHTMLElements } from "svelte/elements"; import Spinner from "$lib/components/ui/Spinner.svelte"; import Check from "$icon/check";
interface Props extends Omit< HTMLAnchorAttributes & HTMLButtonAttributes, "class" | "children" > { variant?: ButtonVariants["variant"]; size?: ButtonVariants["size"]; insetShadow?: boolean; href?: ResolvedPathname; type?: "button" | "submit" | "reset"; disabled?: boolean; icon?: Component<SvelteHTMLElements["svg"]>; iconSide?: "left" | "right"; class?: string; loading?: boolean; holdToConfirm?: boolean; onconfirm?: () => void; spinnerClass?: string; ref?: HTMLAnchorElement | HTMLButtonElement; children?: Snippet; }
let { variant = "default", size = "regular", insetShadow = false, href, type = "button", disabled = false, icon, iconSide = "left", class: className, loading = false, holdToConfirm = false, onconfirm, spinnerClass, ref = $bindable(), children, ...rest }: Props = $props();
const classes = $derived( button({ variant, size, insetShadow, iconSide: icon && children ? iconSide : "none", class: className }) ); const spinnerClasses = $derived(buttonIcon({ size, class: spinnerClass })); const iconClasses = $derived(buttonIcon({ size })); const Icon = $derived(icon); const holdEnabled = $derived(holdToConfirm && !href && type === "button"); const hintId = $props.id(); let progress = $state(0); let holding = $state(false); let confirmed = $state(false);
function hold(node: HTMLButtonElement) { const unavailable = disabled || loading; const controller = new AbortController(); const options = { signal: controller.signal, capture: true }; let owner: number | string | null = null; let origin = { x: 0, y: 0 }; let frame = 0; let resetTimer: ReturnType<typeof setTimeout> | undefined; let last = 0; let elapsed = 0;
function release() { const pointer = typeof owner === "number" ? owner : null; owner = null; holding = false; if (pointer !== null && node.hasPointerCapture(pointer)) { node.releasePointerCapture(pointer); } }
function tick(now: number) { frame = 0; if (disabled || loading || document.hidden || !holdEnabled) release(); const delta = Math.max(0, now - last); last = now; elapsed = Math.max(0, Math.min(1800, elapsed + delta * (holding ? 1 : -2.5))); progress = elapsed / 1800; if (holding && elapsed === 1800) { holding = false; confirmed = true; resetTimer = setTimeout(reset, 1600); onconfirm?.(); return; } if (elapsed > 0 || holding) frame = requestAnimationFrame(tick); }
function begin(input: number | string) { if ( unavailable || disabled || loading || document.hidden || owner !== null || confirmed ) return; owner = input; holding = true; if (!frame) { last = performance.now(); frame = requestAnimationFrame(tick); } }
function reset() { clearTimeout(resetTimer); resetTimer = undefined; cancelAnimationFrame(frame); frame = 0; release(); elapsed = 0; progress = 0; confirmed = false; }
node.addEventListener( "pointerdown", (event) => { if (!event.isPrimary || event.button !== 0 || owner !== null || unavailable) return; node.focus(); begin(event.pointerId); if (owner !== event.pointerId) return; origin = { x: event.clientX, y: event.clientY }; node.setPointerCapture(event.pointerId); }, options ); node.addEventListener( "pointermove", (event) => { if (owner !== event.pointerId) return; const bounds = node.getBoundingClientRect(); if ( Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 10 || event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom ) release(); }, options ); for (const name of [ "pointerup", "pointercancel", "lostpointercapture", "pointerleave" ] as const) { node.addEventListener( name, (event) => { if (owner === event.pointerId) release(); }, options ); } node.addEventListener( "keydown", (event) => { if (event.key !== " " && event.key !== "Enter") return; event.preventDefault(); if (!event.repeat) begin(event.key); }, options ); window.addEventListener( "keyup", (event) => { if (owner === event.key) { event.preventDefault(); release(); } }, options ); window.addEventListener( "keydown", (event) => { if (event.key === "Escape" && (holding || progress > 0)) { event.preventDefault(); reset(); } }, options ); node.addEventListener("blur", release, options); window.addEventListener("blur", release, options); document.addEventListener( "visibilitychange", () => { if (document.hidden) release(); }, options ); node.addEventListener( "click", (event) => { event.preventDefault(); event.stopImmediatePropagation(); }, options ); node.addEventListener("contextmenu", (event) => event.preventDefault(), options);
return () => { controller.abort(); reset(); }; }
setButtonIconSize(() => size);</script>
{#snippet content()} <!-- the transition matches the ::before shadow's so both halves of the press ease together. --> <span class="inline-flex min-w-0 items-center justify-center gap-[inherit] transition-transform duration-150 ease-in-out" > {#if loading} <Spinner class={spinnerClasses} /> {:else} {#if Icon && iconSide == "left"} <Icon class={iconClasses} aria-hidden="true" /> {/if} {#if children} {@render children()} {/if} {#if Icon && iconSide == "right"} <Icon class={iconClasses} aria-hidden="true" /> {/if} {/if} </span>{/snippet}
{#snippet faces()} <span class="hold-faces"> <span class="hold-label">{@render content()}</span> <span class="hold-confirmed" aria-hidden="true"> <Check class={iconClasses} /> Confirmed </span> </span>{/snippet}
{#if href} <a bind:this={ref} {href} class={classes} data-variant={variant} aria-disabled={disabled || loading ? "true" : undefined} tabindex={disabled ? -1 : undefined} {...rest} > {@render content()} </a>{:else if holdEnabled} <button bind:this={ref} {type} disabled={disabled || loading} class={[classes, "hold-button"]} data-variant={variant} data-holding={holding || confirmed} data-confirmed={confirmed} aria-busy={loading} {...rest} aria-describedby={[rest["aria-describedby"], hintId].filter(Boolean).join(" ")} {@attach hold} > {@render faces()} <span class="hold-fill" aria-hidden="true" style:clip-path={`inset(0 ${(1 - progress) * 100}% 0 0)`} > {@render faces()} </span> </button> <span id={hintId} class="sr-only"> Press and hold the pointer, Space, or Enter for 1.8 seconds to confirm. Release early or press Escape to cancel. </span> <span role="status" class="sr-only">{confirmed ? "Confirmed" : ""}</span>{:else} <button bind:this={ref} {type} disabled={disabled || loading} class={classes} data-variant={variant} aria-busy={loading} {...rest} > {@render content()} </button>{/if}
<style> .hold-button { position: relative; isolation: isolate; touch-action: manipulation; -webkit-touch-callout: none; }
.hold-fill { position: absolute; inset: 0; display: flex; align-items: center; justify-content: inherit; gap: inherit; padding: inherit; pointer-events: none; translate: none; background: var(--color-foreground-default); color: var(--color-background-default); }
.hold-button[data-variant="danger"] .hold-fill { background: var(--color-background-danger-emphasis); color: var(--color-foreground-on-emphasis); }
.hold-button[data-variant="primary"] .hold-fill { background: var(--color-background-success-emphasis-hover); color: var(--color-foreground-on-emphasis); }
.hold-faces { display: grid; gap: inherit; }
.hold-label, .hold-confirmed { grid-area: 1 / 1; display: inline-flex; align-items: center; justify-content: center; gap: inherit; transition: opacity 150ms ease; }
.hold-confirmed, .hold-button[data-confirmed="true"] .hold-label { opacity: 0; }
.hold-button[data-confirmed="true"] .hold-confirmed { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .hold-button > span { transition: none; translate: none; }
.hold-fill { clip-path: none !important; opacity: 0; }
.hold-button[data-holding="true"] .hold-fill { opacity: 1; }
.hold-label, .hold-confirmed { transition: none; } }</style>