<script module lang="ts">
import { tv, type VariantProps } from "tailwind-variants";
export const steps = tv({
slots: {
root: "flex w-full items-center gap-xs",
item: "flex flex-1 items-center gap-xs last:flex-none",
control:
"flex items-center gap-xs rounded-md focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-border-focus",
marker: "flex size-6 shrink-0 items-center justify-center rounded-full typography-paragraph-small tabular-nums transition-[scale,background-color,border-color] duration-(--rail-duration) ease-(--rail-ease)",
label: "sr-only typography-paragraph-small whitespace-nowrap sm:not-sr-only",
connector: "relative h-0.5 flex-1 overflow-hidden rounded-full bg-background-subtle",
connectorFill:
"absolute inset-0 origin-left scale-x-0 rounded-full bg-background-info-subtle-hover transition-[scale] duration-(--rail-duration) ease-(--rail-ease) dark:bg-background-subtle"
},
variants: {
state: {
done: {
marker: "bg-background-info-subtle text-foreground-info dark:bg-background-muted dark:text-foreground-default",
label: "text-foreground-default"
},
current: {
marker: "bg-background-info-emphasis text-foreground-on-emphasis",
label: "text-foreground-default"
},
upcoming: {
marker: "bg-background-inset text-foreground-muted",
label: "text-foreground-muted"
}
},
filled: {
true: { connectorFill: "scale-x-100" }
},
here: {
true: { marker: "scale-100" },
false: { marker: "scale-[0.92]" }
},
clickable: {
true: { control: "cursor-pointer" }
}
},
defaultVariants: {
state: "upcoming",
filled: false,
here: false,
clickable: false
}
});
export type StepsVariants = VariantProps<typeof steps>;
export type StepState = NonNullable<StepsVariants["state"]>;
</script>
<script lang="ts">
import Check from "$icon/check";
import { prefersReducedMotion } from "svelte/motion";
import { RAIL_DURATION, RAIL_EASING } from "#lib/motion/spring.ts";
interface Props {
items: readonly { key: string; label: string }[];
current: number;
furthest: number;
complete?: boolean;
onSelect: (index: number) => void;
label?: string;
}
let { items, current, furthest, complete = false, onSelect, label = "Steps" }: Props = $props();
let root = $state<HTMLElement>();
let keyboardMove = false;
const stateOf = (index: number): StepState =>
complete || index < current ? "done" : index === current ? "current" : "upcoming";
const lastIndex = $derived(Math.min(items.length - 1, furthest));
const move = (index: number) => {
const target = Math.max(0, Math.min(lastIndex, index));
if (target === current) return;
keyboardMove = true;
onSelect(target);
};
const onKeydown = (event: KeyboardEvent) => {
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
event.preventDefault();
move(current + 1);
break;
case "ArrowLeft":
case "ArrowUp":
event.preventDefault();
move(current - 1);
break;
case "Home":
event.preventDefault();
move(0);
break;
case "End":
event.preventDefault();
move(lastIndex);
break;
}
};
$effect(() => {
void current;
if (!keyboardMove || !root) return;
keyboardMove = false;
root.querySelector<HTMLButtonElement>('[aria-current="step"]')?.focus();
});
</script>
<ol
bind:this={root}
aria-label={label}
class={steps().root()}
style="--rail-ease: {RAIL_EASING}; --rail-duration: {prefersReducedMotion.current
? 0
: RAIL_DURATION}ms"
>
{#each items as item, index (item.key)}
{@const state = stateOf(index)}
{@const active = index === current}
{@const here = !complete && active}
{@const filled = complete || index < current}
{@const selectable = index <= furthest}
{@const slot = steps({ state, filled, here, clickable: selectable && !active })}
{@const description = `Step ${index + 1} of ${items.length}: ${item.label}`}
<li class={slot.item()}>
{#if selectable}
<button
type="button"
class={slot.control()}
tabindex={active ? 0 : -1}
aria-current={active ? "step" : undefined}
aria-label={description}
onclick={() => {
if (!active) onSelect(index);
}}
onkeydown={onKeydown}
>
<span class={slot.marker()}>
{#if state === "done"}
<Check class="size-3.5" aria-hidden="true" />
<span class="sr-only">Completed</span>
{:else}
{index + 1}
{/if}
</span>
<span class={slot.label()}>{item.label}</span>
</button>
{:else}
<span class={slot.control()} aria-hidden="true">
<span class={slot.marker()}>{index + 1}</span>
<span class={slot.label()}>{item.label}</span>
</span>
<span class="sr-only">{description}</span>
{/if}
{#if index < items.length - 1}
<span aria-hidden="true" class={slot.connector()}>
<span class={slot.connectorFill()}></span>
</span>
{/if}
</li>
{/each}
</ol>