Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169<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-sm 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";
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 here = !complete && index === current} {@const filled = complete || index < current} {@const selectable = index <= furthest} {@const slot = steps({ state, filled, here, clickable: selectable && !here })} {@const description = `Step ${index + 1} of ${items.length}: ${item.label}`} <li class={slot.item()}> {#if selectable} <button type="button" class={slot.control()} tabindex={here ? 0 : -1} aria-current={here ? "step" : undefined} aria-label={description} onclick={() => { if (!here) 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>