Monorepo for Tangled
Something went wrong. Try again.
Svelte
at icy/tmrrpn
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267<script module lang="ts"> export type Range = { start: number; end: number };
export type RowActions = { onclick: () => void; /** * The hover target doesn't have to be the click target, and usually shouldn't be. * A button shorter than its row leaves dead space above and below it, so the preview * drops out every time the cursor crosses from one row to the next. * Wrap the button in a band with the row's minimum height. * e.g. `h-10 flex items-center`. * A taller row still has a gap; hovering it won't hold the preview. */ onpointerenter: () => void; onpointerleave: () => void; };</script>
<script lang="ts" generics="T"> import type { Snippet } from "svelte"; import RangeSelectorRow from "./RangeSelectorRow.svelte"; import { type Band } from "./GraphCell.svelte"; import { rangeFor, type RangeMode } from "./rangeMode";
interface Props { minLength?: number; items: T[]; range?: Range; muteFullSelection?: boolean; /** * What pointing at a row does. Dragging a handle or nudging it with the arrow keys always * moves that 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); let hovered: number | null = $state(null); let flipped = $state(false); let anchorHandle: HandleId = $state("b");
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 } );
// the handle a row moves in "resize" mode: the one that wasn't placed last let mover: HandleId = $derived(sibling(anchorHandle)); let top: HandleId = $derived(flipped ? "b" : "a"); let interacting = $derived(activeHandle !== null || hovered !== 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 >; }
let previewed = $derived( hovered === null ? null : activeHandle ? // a drag moves the grabbed handle, whatever the mode preview(hovered, activeHandle, "resize") : preview(hovered, mover, mode) );
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 muted = $derived( muteFullSelection && selectedRange.start === listRange.start && selectedRange.end === listRange.end );
let palette = $derived( muted && !interacting ? "--range-accent: var(--color-foreground-placeholder); --range-fill: var(--color-background-subtle)" : "--range-accent: var(--color-background-info-emphasis); --range-fill: var(--color-background-info-subtle)" );
let list: HTMLDivElement;
function grab(event: PointerEvent, handle: HandleId) { activeHandle = handle; hovered = null; event.preventDefault(); // captured on the list so the drag keeps tracking once the cursor leaves it list.setPointerCapture(event.pointerId); }
function move(event: PointerEvent) { if (!activeHandle) return;
const row = document .elementFromPoint(event.clientX, event.clientY) ?.closest("[data-index]");
if (!row) return;
// ignoring an illegal row const next = Number((row as HTMLElement).dataset.index); if (preview(next, activeHandle, "resize")) hovered = next; }
function commit(next: Record<HandleId, number>, moved: HandleId) { flipped = next.a > next.b; anchorHandle = moved; range = { start: Math.min(next.a, next.b), end: Math.max(next.a, next.b) }; }
function rowActions(at: number): RowActions { return { onclick: () => { const next = preview(at, mover, mode); if (!next) return; commit(next, mover); // the click already moved a handle and handed `mover` to its sibling; without // this the still cursor would immediately preview *that* handle collapsing // onto the row just clicked hovered = null; }, onpointerenter: () => { if (activeHandle === null && preview(at, mover, mode)) hovered = at; }, onpointerleave: () => { if (activeHandle === null) hovered = null; } }; }
function release() { if (activeHandle !== null && previewed) commit(previewed, activeHandle); activeHandle = null; hovered = null; }
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, handle); }</script>
<div bind:this={list} role="group" aria-label="Range" class:cursor-row-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, displayRange)}> {@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 }, "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 }, "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="z-10 flex size-10 touch-none items-center justify-center hover:cursor-row-resize" class:pointer-events-none={activeHandle !== null} 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} onpointerdown={(e) => grab(e, id)} 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(displayRange, "bg-background-subtle rounded")} {/if} {#if !muted} {@render bandBox( selectedRange, "bg-background-inset rounded border border-border-default" )} {/if} {@render pill(displayRange)} {@render handle("a", "Range boundary 1")} {@render handle("b", "Range boundary 2")} {/if}</div>