<script module lang="ts">
import { getContext, setContext } from "svelte";
import { tv } from "tailwind-variants";
import type { ResolvedPathname } from "$app/types";
export const segmentedControl = tv({
slots: {
group: [
"relative isolate inline-flex items-stretch rounded-md bg-background-inset p-0.5 ring ring-border-default/70 dark:bg-background-canvas",
"[--btn-press:1] [--seg-inset:4px] *:[--btn-radius:calc(var(--radius-md)-2px)]",
"[&>*:focus-visible]:-outline-offset-2"
],
thumb: [
"pointer-events-none absolute top-0 left-0 rounded-(--btn-radius) bg-background-default edge-ring-inset",
"transition-[translate,width,height]"
]
},
variants: {
ready: {
true: { thumb: "opacity-100" },
false: { thumb: "opacity-0" }
}
}
});
interface SegmentedControlContext {
readonly value: string | undefined;
readonly ready: boolean;
href?: (value: string) => ResolvedPathname;
select: (value: string) => void;
}
const SEGMENTED_CONTROL = Symbol("segmented-control");
export const getSegmentedControl = () => getContext<SegmentedControlContext>(SEGMENTED_CONTROL);
</script>
<script lang="ts" generics="T extends string">
import type { Snippet } from "svelte";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { prefersReducedMotion } from "svelte/motion";
import { RAIL_DURATION, RAIL_EASING } from "#lib/motion/spring.ts";
interface Props {
value: T;
href?: (value: T) => ResolvedPathname;
label?: string;
class?: string;
children: Snippet;
}
let { value = $bindable(), href, label, class: className, children }: Props = $props();
let pending = $state<T>();
const current = $derived.by(() => {
const settled = value;
return pending ?? settled;
});
let latest: string | undefined;
beforeNavigate(({ to }) => {
latest = to?.url.href;
});
afterNavigate(({ to }) => {
if (to?.url.href === latest) pending = undefined;
});
let group = $state<HTMLElement>();
let box = $state<{ left: number; top: number; width: number; height: number }>();
let animate = $state(false);
let placed: string | undefined;
const measure = (animated: boolean) => {
const node = group?.querySelector<HTMLElement>(`[data-segment="${CSS.escape(current)}"]`);
const next = node?.offsetParent
? {
left: node.offsetLeft,
top: node.offsetTop,
width: node.offsetWidth,
height: node.offsetHeight
}
: undefined;
animate = animated;
box = next;
return next !== undefined;
};
$effect(() => {
placed = measure(placed !== undefined && placed !== current) ? current : undefined;
});
$effect(() => {
if (!group) return;
const observer = new ResizeObserver(() => measure(false));
observer.observe(group);
for (const node of group.querySelectorAll("[data-segment]")) observer.observe(node);
return () => observer.disconnect();
});
setContext<SegmentedControlContext>(SEGMENTED_CONTROL, {
get value() {
return current;
},
get ready() {
return box !== undefined;
},
get href() {
return href as ((value: string) => ResolvedPathname) | undefined;
},
select: (next) => {
if (href) {
latest = undefined;
pending = next as T;
} else value = next as T;
}
});
const onkeydown = (event: KeyboardEvent) => {
if (!group) return;
const radios = [
...group.querySelectorAll<HTMLButtonElement>('[role="radio"]:not(:disabled)')
];
const index = radios.indexOf(event.target as HTMLButtonElement);
if (index < 0) return;
const target = {
ArrowRight: index + 1,
ArrowDown: index + 1,
ArrowLeft: index - 1,
ArrowUp: index - 1,
Home: 0,
End: radios.length - 1
}[event.key];
if (target === undefined) return;
event.preventDefault();
const radio = radios[(target + radios.length) % radios.length];
radio.focus();
radio.click();
};
const style = $derived(segmentedControl({ ready: box !== undefined }));
</script>
<div
bind:this={group}
role={href ? "group" : "radiogroup"}
aria-label={label}
class={style.group({ class: className })}
onkeydown={href ? undefined : onkeydown}
>
<span
aria-hidden="true"
class={style.thumb()}
style:translate="{box?.left ?? 0}px {box?.top ?? 0}px"
style:width="{box?.width ?? 0}px"
style:height="{box?.height ?? 0}px"
style:transition-duration="{animate && !prefersReducedMotion.current ? RAIL_DURATION : 0}ms"
style:transition-timing-function={RAIL_EASING}
></span>
{@render children()}
</div>