Something went wrong. Try again.
Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367<script module lang="ts"> export type Range = { start: number; end: number };
export type RowActions = { onclick: (event: MouseEvent) => void; onpointerenter: () => void; onpointerleave: () => void; };
export type DotActions = { onclick: () => void; onpointerenter: () => void; onpointerleave: () => void; };</script>
<script lang="ts" generics="T"> import type { Snippet } from "svelte"; import RangeSelectorRow from "$lib/components/ui/RangeSelectorRow.svelte"; import { type Band } from "$lib/components/ui/GraphCell.svelte"; import { untrack } from "svelte"; import { rangeFor, type RangeMode } from "$lib/components/ui/rangeMode";
interface Props { minLength?: number; items: T[]; range?: Range; muteFullSelection?: boolean; /** * What clicking a row does. Shift-clicking a row, pointing at a dot, dragging a handle or * nudging it with the arrow keys always moves one handle freely, whatever the mode. */ mode?: RangeMode; whole?: boolean; row: Snippet<[number, T, RowActions]>; }
let { minLength = 1, items, range = $bindable({ start: 0, end: Infinity }), muteFullSelection = false, mode = "resize", whole: wholeProp, row }: Props = $props();
type Grip = "start" | "end" | "both" | "move";
type Drag = { grip: Grip; origin: Range; from: number; moved: boolean };
let drag: Drag | null = $state(null); let dragRows: Range | null = $state(null); let hovered: number | null = $state(null); let hoveredRow: number | null = $state(null); let shiftHeld = $state(false); // a range the reader asked for outright -- by a dot, a drag or a shift-click. picking a // single row is not one, so it stays in the resting palette let explicit = $state(false);
function clamp(i: number) { return Math.max(0, Math.min(i, items.length - 1)); }
let listRange = $derived({ start: 0, end: items.length - 1 }); let selectedRange = $derived({ start: clamp(range.start), end: clamp(range.end) });
let interacting = $derived(drag !== null || hovered !== null || shiftSpan !== null);
// holding shift over a row shows the span that releasing there would commit let shiftSpan = $derived( shiftHeld && hoveredRow !== null && hovered === null && drag === null ? spanFromAnchor(hoveredRow) : null );
// what the pointer is asking for, in rows. taking it straight from the pointer keeps the // span off the round trip through a half-open range, which would drag in the row below let previewRows = $derived( dragRows ?? (hovered !== null ? rowSpanTo(hovered) : shiftSpan ? rowsOf(shiftSpan) : null) );
// where row `i` sits inside a range, so the row can style its own ends function band(i: number, { start, end }: Range): Band { if (i < start || i > end) return "none"; if (start === end) return "only"; if (i === start) return "first"; if (i === end) return "last"; return "middle"; }
let whole = $derived( (wholeProp ?? true) && selectedRange.start === listRange.start && selectedRange.end === listRange.end );
let displayRows = $derived(previewRows ?? rowsOf(selectedRange, whole));
let gripRows = $derived(dragRows ?? rowsOf(selectedRange, whole));
let maxRow = $derived(items.length - minLength);
let muted = $derived(muteFullSelection && whole);
// the working copy keeps no memory of a range once the reader is back to the whole list $effect(() => { if (!whole) return; untrack(() => (explicit = false)); });
let palette = $derived( !explicit && !interacting ? "--range-accent: var(--color-foreground-placeholder); --range-fill: var(--color-background-inset); --range-mid: var(--color-background-muted); --range-line: light-dark(#e2e4e8, #515968)" : "--range-accent: var(--color-background-info-emphasis); --range-fill: var(--color-background-info-subtle); --range-mid: var(--color-background-info-subtle-hover); --range-line: light-dark(#dae3ff, #403ab3)" );
let list: HTMLDivElement;
function rowAt(y: number) { const rows = list.querySelectorAll<HTMLElement>(":scope > [data-index]"); for (const row of rows) { if (y < row.getBoundingClientRect().bottom) return Number(row.dataset.index); } return items.length - 1; }
function restRows(): Range { const { start, end } = rowsOf(selectedRange, whole); return { start: Math.min(start, maxRow), end: Math.min(end, maxRow) }; }
function within(i: number, lo: number, hi: number) { return Math.max(lo, Math.min(i, hi)); }
function resized(grip: "start" | "end", at: number, { start, end }: Range): Range { return grip === "start" ? { start: within(at, 0, end), end } : { start, end: within(at, start, maxRow) }; }
function pick(next: Range | null) { if (!next) return; explicit = true; range = next; hovered = null; }
function grab(event: PointerEvent, grip: Grip) { if (event.button !== 0) return; event.preventDefault(); list.setPointerCapture(event.pointerId); hovered = null; drag = { grip, origin: restRows(), from: rowAt(event.clientY), moved: false }; }
function move(event: PointerEvent) { if (!drag) return; const at = rowAt(event.clientY); if (at === drag.from && !drag.moved) return; drag.moved = true; const { origin } = drag; if (drag.grip === "both") drag.grip = at < drag.from ? "start" : "end"; if (drag.grip === "move") { const length = origin.end - origin.start; const start = within(origin.start + at - drag.from, 0, maxRow - length); dragRows = { start, end: start + length }; } else { dragRows = resized(drag.grip, at, origin); } }
function release() { if (!drag) return; const { moved, from } = drag; const rows = dragRows; drag = null; dragRows = null; pick(moved ? rows && rangeOf(rows) : spanTo(from)); }
function cancel() { drag = null; dragRows = null; }
// shift-click grows the span from the row last picked outright, the way a list multi-select // does. `minLength - 1` is what carries a half-open list, where the bottom end names the row // past the last one in the span function spanFromAnchor(at: number): Range | null { return spanTo(at); }
// the bottom end names the row past the span when the list is half-open, so the rows a // range actually covers stop short of it -- except at the foot of the list, where that row // is the boundary the range runs to rather than a row it excludes function rowsOf({ start, end }: Range, isWhole = false): Range { return { start, end: isWhole ? end : end - (minLength - 1) }; }
// rows back to a range: the foot of the list is the boundary a range runs to, so a span // reaching it needs no room past its last row function rangeOf({ start, end }: Range): Range { return { start, end: end === items.length - 1 ? end : end + (minLength - 1) }; }
// pointing anywhere grows the span from the end of the selection furthest from it, and the // row pointed at is part of the span rather than the edge past it function rowSpanTo(at: number): Range | null { const rows = rowsOf(selectedRange, whole); if (at === items.length - 1) return null; const anchor = at < rows.start ? rows.end : rows.start; return { start: Math.min(anchor, at), end: Math.max(anchor, at) }; }
function spanTo(at: number): Range | null { const rows = rowSpanTo(at); return rows && rangeOf(rows); }
function rowActions(at: number): RowActions { return { onpointerenter: () => (hoveredRow = at), onpointerleave: () => { if (hoveredRow === at) hoveredRow = null; }, onclick: (event: MouseEvent) => { const next = event.shiftKey ? spanFromAnchor(at) : rangeFor(mode, at, { cur: selectedRange, minLength, count: items.length, moving: "start" }); if (!next) return; explicit = event.shiftKey; range = next; hovered = null; } }; }
function dotActions(at: number): DotActions { return { onclick: () => pick(spanTo(at)), onpointerenter: () => { if (drag === null && spanTo(at)) hovered = at; }, onpointerleave: () => { if (drag === null) hovered = null; } }; }
function handleKeyDown(event: KeyboardEvent, grip: "start" | "end") { const delta = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0; if (!delta) return; event.preventDefault(); const rows = restRows(); pick(rangeOf(resized(grip, rows[grip] + delta, rows))); }</script>
<svelte:window onkeydown={(e) => { shiftHeld = e.shiftKey; if (e.key === "Escape" && drag) cancel(); }} onkeyup={(e) => (shiftHeld = e.shiftKey)} onblur={() => (shiftHeld = false)}/>
<div bind:this={list} role="group" aria-label="Range" class:cursor-ns-resize={drag !== null} class="grid grid-cols-1" style={palette} onpointermove={move} onpointerup={release} onpointercancel={cancel} onlostpointercapture={cancel}> {#each items as item, i (i)} <RangeSelectorRow index={i} position={band(i, listRange)} displayed={band(i, displayRows)} selected={!muted && band(i, displayRows) !== "none"} interactive={rowSpanTo(i) !== null} dot={dotActions(i)} > {@render row(i, item, rowActions(i))} </RangeSelectorRow> {/each}
{#snippet bandBox({ start, end }: Range, styles: string)} <div class="pointer-events-none {styles}" style="grid-column: 1; grid-row: {start + 1} / {end + 2}" ></div> {/snippet}
{#snippet pill({ start, end }: Range)} {#if end > start} {@render bandBox( { start, end: end - 1 }, "relative z-10 mt-2.5 -mb-7.5 ml-2.5 w-5 rounded-full bg-(--range-fill) outline-1 outline-border-default" )} {:else} {@render bandBox( { start: end, end }, "relative z-10 mt-2.5 ml-2.5 size-5 self-start rounded-full bg-(--range-fill) outline-1 outline-border-default" )} {/if} {/snippet}
{#snippet handle(grip: "start" | "end", label: string)} {@const single = gripRows.start === gripRows.end} <div class="z-40 h-5 w-10 cursor-ns-resize touch-none self-start {grip === 'end' ? 'mt-5' : ''}" class:h-10={single && grip === "start"} class:pointer-events-none={single && grip === "end"} style="grid-column: 1; grid-row: {gripRows[grip] + 1}" role="slider" tabindex="0" aria-label={label} aria-valuenow={gripRows[grip]} aria-valuemin={grip === "start" ? 0 : gripRows.start} aria-valuemax={grip === "start" ? gripRows.end : maxRow} onpointerdown={(e) => grab(e, single ? "both" : grip)} onkeydown={(e) => handleKeyDown(e, grip)} title="Drag or use arrow keys to adjust the range" ></div> {/snippet}
{#if items.length > 0} {#if interacting} {@render bandBox(displayRows, "bg-background-subtle rounded")} {/if} {#if !muted} {@render bandBox(rowsOf(selectedRange, whole), "bg-background-subtle rounded")} <!-- the rows paint over the fill below them, so the outline rides above instead --> {@render bandBox( rowsOf(selectedRange, whole), "relative z-10 rounded border border-border-default dark:border-border-strong" )} {/if} {@render pill(displayRows)} {#if gripRows.end > gripRows.start} <div class="z-30 my-5 w-10 cursor-ns-resize touch-none" style="grid-column: 1; grid-row: {gripRows.start + 1} / {gripRows.end + 2}" aria-hidden="true" onpointerdown={(e) => grab(e, "move")} ></div> {/if} {@render handle("start", "Range start")} {@render handle("end", "Range end")} {/if}</div>