Monorepo for Tangled
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410<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; onpointerdown: () => void; onpointerup: () => 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; row: Snippet<[number, T, RowActions]>; }
let { minLength = 1, items, range = $bindable({ start: 0, end: Infinity }), muteFullSelection = false, mode = "resize", row }: Props = $props();
type HandleId = "a" | "b";
let activeHandle: HandleId | null = $state(null); // a press on a dot that has since travelled over other rows: the same span shift-click // builds, followed live. no capture and no preventDefault, or the click never arrives let pressing = false; let pressedAt: number | null = null; let dragMoved = false; let justDragged = false; 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); let flipped = $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 resting = $derived( flipped ? { a: selectedRange.end, b: selectedRange.start } : { a: selectedRange.start, b: selectedRange.end } );
let top: HandleId = $derived(flipped ? "b" : "a"); let interacting = $derived(activeHandle !== null || hovered !== null || shiftSpan !== null);
function sibling(handle: HandleId): HandleId { return handle === "a" ? "b" : "a"; }
// handle positions for pointing at row `at` with `handle` moving, or null if illegal function preview(at: number, handle: HandleId, m: RangeMode) { const span = rangeFor(m, at, { cur: selectedRange, minLength, count: items.length, moving: handle === top ? "start" : "end" }); if (!span) return null;
// only "resize" lets the moved handle cross the other and become the new top const movedTop = m === "resize" ? (at <= span.start ? handle : sibling(handle)) : top; return { [movedTop]: span.start, [sibling(movedTop)]: span.end } as Record< HandleId, number >; }
// holding shift over a row shows the span that releasing there would commit let shiftSpan = $derived( shiftHeld && hoveredRow !== null && hovered === null && activeHandle === null ? spanFromAnchor(hoveredRow) : null );
let dotSpan = $derived(hovered === null ? null : spanTo(hovered));
// 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( activeHandle !== null ? null : hovered !== null ? rowSpanTo(hovered) : shiftSpan ? rowsOf(shiftSpan) : null );
let previewed = $derived( hovered === null ? shiftSpan ? ({ [top]: shiftSpan.start, [sibling(top)]: shiftSpan.end } as Record< HandleId, number >) : null : activeHandle ? // a drag moves the grabbed handle, whatever the mode preview(hovered, activeHandle, "resize") : dotSpan ? ({ [top]: dotSpan.start, [sibling(top)]: dotSpan.end } as Record< HandleId, number >) : null );
let pos = $derived(previewed ?? resting);
// 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"; }
// just the span between the two handles -- at rest that's `selectedRange` let displayRange = $derived({ start: Math.min(pos.a, pos.b), end: Math.max(pos.a, pos.b) });
let displayRows = $derived(previewRows ?? rowsOf(displayRange));
let whole = $derived( selectedRange.start === listRange.start && selectedRange.end === listRange.end );
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(() => { flipped = false; 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 move(event: PointerEvent) { if (!pressing) return; const row = document .elementFromPoint(event.clientX, event.clientY) ?.closest("[data-index]"); if (!row) return; const next = Number((row as HTMLElement).dataset.index); if (!rowSpanTo(next)) return; if (next !== pressedAt) dragMoved = true; hovered = next; }
function commit(next: Record<HandleId, number>) { flipped = next.a > next.b; range = { start: Math.min(next.a, next.b), end: Math.max(next.a, next.b) }; }
// 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): Range { const last = items.length - 1; const whole = start === 0 && end === last; return { start, end: whole ? 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); 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; // the top handle holds the row just picked, so pointing at a dot next moves the // bottom end and leaves that row where it is flipped = false; range = next; hovered = null; } }; }
function dotActions(at: number): DotActions { return { onpointerdown: () => { pressing = true; pressedAt = at; dragMoved = false; // a drag ending on another dot fires no click on this one, so the guard the // drag raised has to come down here rather than waiting for one justDragged = false; }, // a drag ends on whichever dot it travelled to, so that dot settles the span onpointerup: () => { if (!pressing) return; const moved = dragMoved; pressing = false; pressedAt = null; dragMoved = false; if (!moved) return; const next = spanTo(at); if (!next) return; justDragged = true; explicit = true; flipped = false; range = next; hovered = null; }, onclick: () => { if (justDragged) { justDragged = false; return; } const next = spanTo(at); if (!next) return; explicit = true; flipped = false; range = next; hovered = null; }, onpointerenter: () => { if (activeHandle === null && spanTo(at)) hovered = at; }, onpointerleave: () => { if (activeHandle === null) hovered = null; } }; }
// a press let go anywhere that is not a dot just ends function release() { pressing = false; pressedAt = null; dragMoved = false; }
function handleKeyDown(event: KeyboardEvent, handle: HandleId) { const delta = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0; if (!delta) return;
const next = preview(pos[handle] + delta, handle, "resize"); if (!next) return;
event.preventDefault(); commit(next); }</script>
<svelte:window onkeydown={(e) => (shiftHeld = e.shiftKey)} onkeyup={(e) => (shiftHeld = e.shiftKey)} onblur={() => (shiftHeld = false)}/>
<div bind:this={list} role="group" aria-label="Range" class:cursor-ns-resize={activeHandle !== null} class="grid grid-cols-1" style={palette} onpointermove={move} onpointerup={release} onpointercancel={release}> {#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(id: HandleId, label: string)} <div class="pointer-events-none z-10 flex size-10 items-center justify-center" style="grid-column: 1; grid-row: {pos[id] + 1}" role="slider" tabindex="0" aria-label={label} aria-valuenow={pos[id]} aria-valuemin={0} aria-valuemax={items.length - 1} onkeydown={(e) => handleKeyDown(e, id)} 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), "bg-background-subtle rounded")} <!-- the rows paint over the fill below them, so the outline rides above instead --> {@render bandBox( rowsOf(selectedRange), "relative z-10 rounded border border-border-default dark:border-border-strong" )} {/if} {@render pill(displayRows)} {@render handle("a", "Range boundary 1")} {@render handle("b", "Range boundary 2")} {/if}</div>