<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 {
// a function gets called on click, not when the button renders
value: string | (() => 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 {
const text = typeof value === "function" ? value() : value;
await navigator.clipboard.writeText(text);
status = "copied";
onCopy?.(text);
} 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>