From cb7eb867c9be7bb66597615f094283f191ff52be Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Wed, 19 Aug 2026 23:27:14 +0900 Subject: [PATCH] web: change RangeSelector behavior Signed-off-by: Seongmin Lee --- .../repo/pulls/PullCommitListDiff.svelte | 1 + .../repo/pulls/PullVersionList.svelte | 1 + .../ui/RangeSelector.stories.svelte | 59 +++++++++++---- .../lib/components/ui/RangeSelector.svelte | 71 +++++++++++++------ web/src/lib/components/ui/rangeMode.test.ts | 64 +++++++++++++++++ web/src/lib/components/ui/rangeMode.ts | 46 ++++++++++++ 6 files changed, 206 insertions(+), 36 deletions(-) create mode 100644 web/src/lib/components/ui/rangeMode.test.ts create mode 100644 web/src/lib/components/ui/rangeMode.ts diff --git a/web/src/lib/components/repo/pulls/PullCommitListDiff.svelte b/web/src/lib/components/repo/pulls/PullCommitListDiff.svelte index 85c18592a..7b8cc3e91 100644 --- a/web/src/lib/components/repo/pulls/PullCommitListDiff.svelte +++ b/web/src/lib/components/repo/pulls/PullCommitListDiff.svelte @@ -46,5 +46,6 @@ {row} minLength={2} muteFullSelection + mode="select-top" bind:range={() => commitRange, (next) => oncommitCommits(next)} /> diff --git a/web/src/lib/components/repo/pulls/PullVersionList.svelte b/web/src/lib/components/repo/pulls/PullVersionList.svelte index 6b5308b75..ddf7f8975 100644 --- a/web/src/lib/components/repo/pulls/PullVersionList.svelte +++ b/web/src/lib/components/repo/pulls/PullVersionList.svelte @@ -67,6 +67,7 @@ selected, (next) => (pending = next)} />
diff --git a/web/src/lib/components/ui/RangeSelector.stories.svelte b/web/src/lib/components/ui/RangeSelector.stories.svelte index 5a1731744..b4b5a293b 100644 --- a/web/src/lib/components/ui/RangeSelector.stories.svelte +++ b/web/src/lib/components/ui/RangeSelector.stories.svelte @@ -7,11 +7,28 @@ component: RangeSelector, tags: ["autodocs"], args: { + items: [1, 2, 3, 4, 5, 6, 7, 8], minLength: 1, - muteFullSelection: false + muteFullSelection: false, + mode: "resize" }, argTypes: { - minLength: { control: { type: "number", min: 1 } } + items: { control: { type: "object" } }, + minLength: { control: { type: "number", min: 1 } }, + mode: { + control: { type: "select" }, + options: [ + "resize", + "resize-top", + "resize-top-push", + "resize-bottom", + "resize-bottom-push", + "select-top", + "select-bottom" + ] + }, + range: { control: false }, + row: { control: false } } }); @@ -21,10 +38,13 @@ import Link from "./Link.svelte"; import Tag from "./Tag.svelte"; - let bound = $state({ start: 1, end: 3 }); + let plain = $state({ start: 1, end: 3 }); + let long = $state({ start: 1, end: 3 }); + let full = $state({ start: 0, end: 5 }); + let byMode = $state({ start: 1, end: 3 }); -{#snippet row(index: number, item: number, actions: RowActions)} +{#snippet row(index: number, item: unknown, actions: RowActions)}
{/snippet} - - {#snippet template()} + + {#snippet template(args)}
- - {bound.start}..{bound.end} + + {plain.start}..{plain.end}
{/snippet}
- - {#snippet template()} + + {#snippet template(args)}
- + + {long.start}..{long.end}
{/snippet}
- - {#snippet template({ muteFullSelection })} + + {#snippet template(args)}
- + + {full.start}..{full.end} +
+ {/snippet} +
+ + + {#snippet template(args)} +
+ + {byMode.start}..{byMode.end}
{/snippet}
diff --git a/web/src/lib/components/ui/RangeSelector.svelte b/web/src/lib/components/ui/RangeSelector.svelte index 108f236e6..64bd4d5f8 100644 --- a/web/src/lib/components/ui/RangeSelector.svelte +++ b/web/src/lib/components/ui/RangeSelector.svelte @@ -20,12 +20,18 @@ 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]>; } @@ -34,6 +40,7 @@ items, range = $bindable({ start: 0, end: Infinity }), muteFullSelection = false, + mode = "resize", row }: Props = $props(); @@ -57,22 +64,43 @@ : { a: selectedRange.start, b: selectedRange.end } ); - let mover: HandleId = $derived(activeHandle ?? sibling(anchorHandle)); + // 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); - let pos = $derived(hovered !== null ? { ...resting, [mover]: hovered } : resting); - function sibling(handle: HandleId): HandleId { return handle === "a" ? "b" : "a"; } - function other(handle: HandleId) { - return resting[sibling(handle)]; + // 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 + >; } - function legal(next: number, handle: HandleId) { - return Math.abs(next - other(handle)) + 1 >= minLength; - } + 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 { @@ -119,28 +147,28 @@ // ignoring an illegal row const next = Number((row as HTMLElement).dataset.index); - if (legal(next, mover)) hovered = next; + if (preview(next, activeHandle, "resize")) hovered = next; } - function commit(handle: HandleId, at: number) { - const fixed = other(handle); - flipped = handle === "a" ? at > fixed : at < fixed; - anchorHandle = handle; - range = { start: Math.min(at, fixed), end: Math.max(at, fixed) }; + function commit(next: Record, 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: () => { - if (!legal(at, mover)) return; - commit(mover, at); + 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 && legal(at, mover)) hovered = at; + if (activeHandle === null && preview(at, mover, mode)) hovered = at; }, onpointerleave: () => { if (activeHandle === null) hovered = null; @@ -149,7 +177,7 @@ } function release() { - if (activeHandle !== null && hovered !== null) commit(activeHandle, hovered); + if (activeHandle !== null && previewed) commit(previewed, activeHandle); activeHandle = null; hovered = null; } @@ -158,12 +186,11 @@ const delta = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0; if (!delta) return; - const next = pos[handle] + delta; - if (next < 0 || next >= items.length) return; - if (!legal(next, handle)) return; + const next = preview(pos[handle] + delta, handle, "resize"); + if (!next) return; event.preventDefault(); - commit(handle, next); + commit(next, handle); } diff --git a/web/src/lib/components/ui/rangeMode.test.ts b/web/src/lib/components/ui/rangeMode.test.ts new file mode 100644 index 000000000..0f1e17946 --- /dev/null +++ b/web/src/lib/components/ui/rangeMode.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { rangeFor, type RangeMode } from "./rangeMode"; + +// 5 rows, the range sits on 1..3, and nothing shorter than 3 rows is legal +const at = (mode: RangeMode, row: number, moving: "start" | "end" = "start") => + rangeFor(mode, row, { cur: { start: 1, end: 3 }, minLength: 3, count: 5, moving }); + +describe("rangeFor", () => { + it("resizes the moving end and keeps the other fixed", () => { + expect(at("resize", 0)).toEqual({ start: 0, end: 3 }); + expect(at("resize", 1)).toEqual({ start: 1, end: 3 }); + expect(at("resize", 4, "end")).toEqual({ start: 1, end: 4 }); + // too close to the fixed end + expect(at("resize", 2)).toBeNull(); + }); + + it("lets the moving end cross the other in resize", () => { + const crossed = rangeFor("resize", 4, { + cur: { start: 1, end: 3 }, + minLength: 1, + count: 5, + moving: "start" + }); + expect(crossed).toEqual({ start: 3, end: 4 }); + }); + + it("keeps resize-top and resize-bottom one-sided", () => { + expect(at("resize-top", 0)).toEqual({ start: 0, end: 3 }); + expect(at("resize-top", 1)).toEqual({ start: 1, end: 3 }); + expect(at("resize-top", 2)).toBeNull(); + + expect(at("resize-bottom", 4)).toEqual({ start: 1, end: 4 }); + expect(at("resize-bottom", 3)).toEqual({ start: 1, end: 3 }); + expect(at("resize-bottom", 2)).toBeNull(); + }); + + it("pushes the far end only as far as minLength needs", () => { + expect(at("resize-top-push", 0)).toEqual({ start: 0, end: 3 }); + expect(at("resize-top-push", 2)).toEqual({ start: 2, end: 4 }); + // pushing the bottom to row 5 would run off the list + expect(at("resize-top-push", 3)).toBeNull(); + + expect(at("resize-bottom-push", 4)).toEqual({ start: 1, end: 4 }); + expect(at("resize-bottom-push", 2)).toEqual({ start: 0, end: 2 }); + expect(at("resize-bottom-push", 1)).toBeNull(); + }); + + it("selects a minLength span anchored on the pointed row", () => { + expect(at("select-top", 0)).toEqual({ start: 0, end: 2 }); + expect(at("select-top", 2)).toEqual({ start: 2, end: 4 }); + expect(at("select-top", 3)).toBeNull(); + expect(at("select-top", 4)).toBeNull(); + + expect(at("select-bottom", 4)).toEqual({ start: 2, end: 4 }); + expect(at("select-bottom", 2)).toEqual({ start: 0, end: 2 }); + expect(at("select-bottom", 1)).toBeNull(); + }); + + it("selects the single pointed row at the default minLength", () => { + const one = { cur: { start: 1, end: 3 }, minLength: 1, count: 5, moving: "start" } as const; + expect(rangeFor("select-top", 4, one)).toEqual({ start: 4, end: 4 }); + expect(rangeFor("select-bottom", 0, one)).toEqual({ start: 0, end: 0 }); + }); +}); diff --git a/web/src/lib/components/ui/rangeMode.ts b/web/src/lib/components/ui/rangeMode.ts new file mode 100644 index 000000000..add61d930 --- /dev/null +++ b/web/src/lib/components/ui/rangeMode.ts @@ -0,0 +1,46 @@ +import type { Range } from "./RangeSelector.svelte"; + +export type RangeMode = + | "resize" + | "resize-top" + | "resize-top-push" + | "resize-bottom" + | "resize-bottom-push" + | "select-top" + | "select-bottom"; + +type Options = { + cur: Range; + minLength: number; + count: number; + /** which end of `cur` follows the pointer -- "resize" only */ + moving: "start" | "end"; +}; + +/** the span for pointing at row `at`, or null when it won't fit the list or `minLength` */ +export function rangeFor(mode: RangeMode, at: number, o: Options): Range | null { + const { cur, minLength, count } = o; + + const fit = (start: number, end: number) => + start >= 0 && end < count && end - start + 1 >= minLength ? { start, end } : null; + + switch (mode) { + case "resize": { + const fixed = o.moving === "start" ? cur.end : cur.start; + return fit(Math.min(at, fixed), Math.max(at, fixed)); + } + // the sub-minLength span past the other handle is what keeps these one-sided + case "resize-top": + return fit(at, cur.end); + case "resize-bottom": + return fit(cur.start, at); + case "resize-top-push": + return fit(at, Math.max(cur.end, at + minLength - 1)); + case "resize-bottom-push": + return fit(Math.min(cur.start, at - minLength + 1), at); + case "select-top": + return fit(at, at + minLength - 1); + case "select-bottom": + return fit(at - minLength + 1, at); + } +} -- 2.51.2