Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210<script module lang="ts"> export type CopyStatus = "idle" | "copied" | "error";</script>
<script lang="ts"> import type { ResolvedPathname } from "$app/types"; import type { Component } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; import Copy from "$icon/copy"; import X from "$icon/x"; import { CROSSFADE_DURATION, CROSSFADE_EASING, DRAW_DURATION, DRAW_EASING } from "#lib/motion/spring.ts"; import { prefersReducedMotion } from "svelte/motion"; import type { Snippet } from "svelte"; import Button, { buttonIcon, type ButtonVariants } from "#lib/components/ui/Button.svelte";
interface Props { value: string; href?: ResolvedPathname; icon?: Component<SvelteHTMLElements["svg"]>; label?: string; copiedLabel?: string; errorLabel?: string; showLabel?: boolean; timeout?: number; variant?: ButtonVariants["variant"]; size?: ButtonVariants["size"]; disabled?: boolean; onCopy?: (value: string) => void; onError?: (reason: unknown) => void; title?: string; "aria-label"?: string; class?: string; children?: Snippet; feedback?: "label" | "icon"; }
let { value, href, icon: Icon = Copy, label = "Copy", copiedLabel = "Copied", errorLabel = "Failed", showLabel = false, timeout = 2000, variant = "ghost", size = "small", disabled = false, onCopy, onError, class: className, title, "aria-label": ariaLabel, children, feedback = "label" }: Props = $props();
let status = $state<CopyStatus>("idle"); let labelWidths = $state<Partial<Record<CopyStatus, number>>>({}); let timer: ReturnType<typeof setTimeout> | undefined;
$effect(() => () => clearTimeout(timer));
const copy = async () => { try { await navigator.clipboard.writeText(value); status = "copied"; onCopy?.(value); } catch (reason) { status = "error"; onError?.(reason); } clearTimeout(timer); timer = setTimeout(() => (status = "idle"), timeout); };
const iconSize = $derived(buttonIcon({ size })); const draw = $derived(prefersReducedMotion.current ? 0 : DRAW_DURATION); const layer = (key: CopyStatus) => `swap-layer ${iconSize}${key === status ? "" : " swap-icon-out"}`; const labels = $derived<[CopyStatus, string][]>([ ["idle", label], ["copied", copiedLabel], ["error", errorLabel] ]);</script>
{#snippet icon()} <span class="cell {iconSize} grid shrink-0" data-status={status} aria-hidden="true"> <Icon class={layer("idle")} /> {@render check()} <X class={layer("error")} /> </span>{/snippet}
{#snippet check()} <!-- lucide's check reversed, so the stroke draws from the short leg up to the tip the way a hand would write it. pathLength normalises the dash to 1. --> <svg class={layer("copied")} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" > <path class="check" pathLength="1" d="M4 12l5 5L20 6" /> </svg>{/snippet}
<Button {href} {variant} {size} {disabled} class={className} {title} aria-label={ariaLabel ?? label} onclick={(event) => { event.preventDefault(); void copy(); }} style="--swap-duration: {CROSSFADE_DURATION}ms; --swap-easing: {CROSSFADE_EASING}; --copy-draw: {draw}ms; --copy-draw-ease: {DRAW_EASING}"> {#if children && feedback === "icon"} <span class="flex items-center"> <span class={[ "grid transition-[grid-template-columns] duration-(--swap-duration) ease-(--swap-easing)", status === "idle" ? "grid-cols-[0fr]" : "grid-cols-[1fr]" ]} aria-hidden="true" > <span class="flex overflow-hidden"> <span class="cell {iconSize} mr-2xs grid shrink-0" data-status={status}> {@render check()} <X class={layer("error")} /> </span> </span> </span> <span class="whitespace-nowrap">{@render children()}</span> </span> {:else if children} <span class="grid justify-items-center"> <span class={["whitespace-nowrap swap-layer", status !== "idle" && "swap-label-out"]}> {@render children()} </span> <span class={[ "flex items-center gap-2xs swap-layer", status === "idle" && "swap-label-out" ]} aria-hidden="true" > {@render icon()} <span class="grid font-sans"> {#each labels.filter(([key]) => key !== "idle") as [key, text] (key)} <span class={[ "whitespace-nowrap swap-layer", key !== status && "swap-label-out" ]}>{text}</span > {/each} </span> </span> </span> {:else} {@render icon()} {#if showLabel} <span class="relative grid justify-items-center transition-[width] duration-(--swap-duration) ease-(--swap-easing)" style:width={labelWidths[status] === undefined ? undefined : `${labelWidths[status]}px`} aria-hidden="true" > {#each labels as [key, text] (key)} <span class={[ "whitespace-nowrap swap-layer", key !== status && "absolute inset-x-0 mx-auto w-max swap-label-out" ]} bind:offsetWidth={labelWidths[key]}>{text}</span > {/each} </span> {/if} {/if} <span role="status" aria-live="polite" class="sr-only"> {status === "copied" ? copiedLabel : status === "error" ? errorLabel : ""} </span></Button>
<style> .check { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset var(--copy-draw) var(--copy-draw-ease); }
.cell[data-status="copied"] .check { stroke-dashoffset: 0; }</style>