<script module lang="ts">
import { getContext, setContext } from "svelte";
import { tv, type VariantProps } from "tailwind-variants";
const emphasis = [
"[--btn-from:color-mix(in_oklch,var(--btn-brand),white_15%)] [--btn-highlight:color-mix(in_oklch,var(--btn-brand),white_30%)] [--btn-ring:color-mix(in_oklch,var(--btn-brand),black_10%)] [--btn-ring-from:color-mix(in_oklch,var(--btn-from),black_10%)]",
"bg-(--btn-highlight) bg-linear-to-b from-(--btn-from) to-(--btn-brand) text-white text-shadow-2xs/25 [&_svg:not(.animate-spin)]:drop-shadow-[0_1px_0_rgb(0_0_0/0.25)] [&_[data-spinner]]:drop-shadow-[0_1px_0_rgb(0_0_0/0.25)] shadow-[inset_0_2px_0_0_var(--btn-highlight)] edge-ring-inset inset-ring-transparent",
"after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:p-px after:bg-linear-to-b after:from-(--btn-ring-from) after:to-(--btn-ring) after:[mask:linear-gradient(#000_0_0)_content-box_exclude,linear-gradient(#000_0_0)] after:transition-[--tw-gradient-from] after:duration-150 after:ease-out-strong",
"hover:from-(--btn-highlight) hover:[--btn-ring-from:color-mix(in_oklch,var(--btn-highlight),black_10%)] aria-expanded:from-(--btn-highlight) aria-expanded:[--btn-ring-from:color-mix(in_oklch,var(--btn-highlight),black_10%)]",
"focus-visible:outline-(--btn-brand)",
"disabled:opacity-50 aria-disabled:opacity-50"
];
export const button = tv({
base: [
"relative inline-flex cursor-pointer items-center justify-center overflow-hidden rounded-l-(--btn-radius-l) rounded-r-(--btn-radius-r) text-center no-underline select-none [--btn-radius-l:var(--btn-radius)] [--btn-radius-r:var(--btn-radius)] [--btn-radius:var(--radius-md)]",
"hover:no-underline",
"transition-[scale,background-color,color,--tw-gradient-from] duration-150 ease-out-strong",
"active:scale-(--btn-press,0.97) motion-reduce:active:scale-100",
"focus-visible:outline-2 focus-visible:outline-offset-2",
"disabled:pointer-events-none aria-disabled:pointer-events-none"
],
variants: {
variant: {
default: [
"bg-background-default text-foreground-default edge-ring-inset",
"hover:bg-background-inset aria-expanded:bg-background-subtle",
"focus-visible:outline-border-focus",
"disabled:bg-background-subtle disabled:text-foreground-subtle disabled:inset-ring-transparent",
"aria-disabled:bg-background-subtle aria-disabled:text-foreground-subtle aria-disabled:inset-ring-transparent"
],
primary: ["[--btn-brand:var(--color-background-info-emphasis)]", ...emphasis],
destructive: ["[--btn-brand:var(--color-red-700)]", ...emphasis],
ghost: [
"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: [
"bg-background-default text-foreground-danger edge-ring-inset",
"hover:bg-background-subtle aria-expanded:bg-background-subtle",
"focus-visible:outline-foreground-danger",
"disabled:text-foreground-danger-disabled disabled:inset-ring-border-subtle aria-disabled:text-foreground-danger-disabled"
],
success: [
"bg-background-default text-foreground-success edge-ring-inset",
"hover:bg-background-subtle aria-expanded:bg-background-subtle",
"focus-visible:outline-foreground-success",
"disabled:text-foreground-success-disabled disabled:inset-ring-border-subtle aria-disabled:text-foreground-success-disabled"
],
warning: [
"bg-background-default text-foreground-warning edge-ring-inset",
"hover:bg-background-subtle aria-expanded:bg-background-subtle",
"focus-visible:outline-foreground-warning",
"disabled:text-foreground-warning-disabled disabled:inset-ring-border-subtle aria-disabled:text-foreground-warning-disabled"
]
},
size: {
mini: "min-h-[calc(1.5rem-var(--seg-inset,0px))] min-w-xl gap-2xs px-2xs py-3xs typography-paragraph-mini [--btn-radius:0.25rem]",
small: "min-h-[calc(1.75rem-var(--seg-inset,0px))] gap-2xs px-2 py-3xs typography-paragraph-small [--btn-radius:var(--radius-sm)]",
regular:
"min-h-[calc(2rem-var(--seg-inset,0px))] gap-1.5 px-sm py-3xs typography-paragraph-regular",
large: "min-h-[calc(2.25rem-var(--seg-inset,0px))] gap-xs px-sm py-3xs typography-paragraph-large"
},
iconSide: {
left: "",
right: "",
none: ""
},
square: {
true: "min-w-0 px-0",
false: ""
},
hideLabelOnMobile: {
true: "mobile:min-w-0 mobile:px-0",
false: ""
}
},
compoundVariants: [
{ square: true, size: "mini", class: "w-[calc(1.5rem-var(--seg-inset,0px))]" },
{ square: true, size: "small", class: "w-[calc(1.75rem-var(--seg-inset,0px))]" },
{ square: true, size: "regular", class: "w-[calc(2rem-var(--seg-inset,0px))]" },
{ square: true, size: "large", class: "w-[calc(2.25rem-var(--seg-inset,0px))]" },
{
hideLabelOnMobile: true,
size: "mini",
class: "mobile:w-[calc(1.5rem-var(--seg-inset,0px))]"
},
{
hideLabelOnMobile: true,
size: "small",
class: "mobile:w-[calc(1.75rem-var(--seg-inset,0px))]"
},
{
hideLabelOnMobile: true,
size: "regular",
class: "mobile:w-[calc(2rem-var(--seg-inset,0px))]"
},
{
hideLabelOnMobile: true,
size: "large",
class: "mobile:w-[calc(2.25rem-var(--seg-inset,0px))]"
},
{ 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" }
],
defaultVariants: {
variant: "default",
size: "regular",
iconSide: "none",
square: false,
hideLabelOnMobile: 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";
import { on } from "svelte/events";
interface Props extends Omit<
HTMLAnchorAttributes & HTMLButtonAttributes,
"class" | "children"
> {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
square?: boolean;
href?: ResolvedPathname;
type?: "button" | "submit" | "reset";
disabled?: boolean;
icon?: Component<SvelteHTMLElements["svg"]>;
iconSide?: "left" | "right";
hideLabelOnMobile?: boolean;
class?: string;
loading?: boolean;
holdToConfirm?: boolean;
onconfirm?: () => void;
spinnerClass?: string;
ref?: HTMLAnchorElement | HTMLButtonElement;
children?: Snippet;
}
let {
variant = "default",
size = "regular",
square,
href,
type = "button",
disabled = false,
icon,
iconSide = "left",
hideLabelOnMobile = false,
class: className,
loading = false,
holdToConfirm = false,
onconfirm,
spinnerClass,
ref = $bindable(),
children,
...rest
}: Props = $props();
const classes = $derived(
button({
variant,
size,
square: square ?? Boolean(icon && !children),
iconSide: icon && children ? iconSide : "none",
hideLabelOnMobile: Boolean(icon && children && hideLabelOnMobile),
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 spaceActivates(node: HTMLAnchorElement) {
return on(node, "keydown", (event) => {
if (event.key !== " " || event.defaultPrevented) return;
event.preventDefault();
node.click();
});
}
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",
loading && "invisible"
]}
>
{#if Icon && iconSide == "left"}
<Icon class={iconClasses} aria-hidden="true" />
{/if}
{#if children && Icon && hideLabelOnMobile}
<span class="mobile:sr-only">{@render children()}</span>
{:else if children}
{@render children()}
{/if}
{#if Icon && iconSide == "right"}
<Icon class={iconClasses} aria-hidden="true" />
{/if}
</span>
{#if loading}
<span data-spinner class="absolute inset-0 flex items-center justify-center">
<Spinner class={spinnerClasses} />
</span>
{/if}
{/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}
{@attach spaceActivates}
>
{@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="destructive"] .hold-fill {
background: linear-gradient(
to bottom,
color-mix(in oklch, var(--btn-from), black 0%),
color-mix(in oklch, var(--btn-brand), black 10%)
);
box-shadow: inset 0 2px 0 0 color-mix(in oklch, var(--btn-highlight), black 5%);
color: white;
}
.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>