feat: shift-click to select a range of rows (closes #525)

Clicking a row's checkbox with Shift held takes every row between it and
the last one toggled to the state the click produced, so a stretch of a
long queue can be cancelled without ticking each entry.

The range follows the order the rows are rendered in, which for the
Completed list is the sort the user picked rather than the order the map
holds; the master checkbox takes that order as an input. An anchor whose
row has since left the list (a download finishing moves it to Completed)
falls back to a plain toggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tjelite1986
2026-08-16 12:49:39 +02:00
parent aac9c63a36
commit 75fe1f0c11
6 changed files with 206 additions and 4 deletions
@@ -20,17 +20,33 @@ import { FormsModule } from "@angular/forms";
export class SelectAllCheckboxComponent {
readonly id = input.required<string>();
readonly list = input.required<Map<string, Checkable>>();
// The ids in the order the rows are rendered. The done list is sorted for
// display, so its order is not the map's insertion order, and a range
// selection has to follow what the user sees. Left unset, the map order is
// the rendered order.
readonly orderedIds = input<string[] | null>(null);
readonly changed = output<number>();
readonly masterCheckbox = viewChild.required<ElementRef>('masterCheckbox');
selected!: boolean;
// The item a range extends from: the last one toggled on its own.
private anchorId: string | null = null;
clicked() {
this.list().forEach(item => item.checked = this.selected);
// Select-all is not a position, so there is nothing to extend from next.
this.anchorId = null;
this.selectionChanged();
}
selectionChanged() {
selectionChanged(id?: string, extend = false) {
if (id !== undefined) {
if (extend && this.anchorId !== null && this.anchorId !== id) {
this.applyRange(this.anchorId, id);
}
this.anchorId = id;
}
const masterCheckbox = this.masterCheckbox();
if (!masterCheckbox)
return;
@@ -40,4 +56,27 @@ export class SelectAllCheckboxComponent {
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
this.changed.emit(checked);
}
// Everything between the anchor and the just-clicked row takes the state the
// click produced, so shift-clicking a checked box clears the range and
// shift-clicking an unchecked one fills it.
private applyRange(fromId: string, toId: string) {
const ids = this.orderedIds() ?? Array.from(this.list().keys());
const from = ids.indexOf(fromId);
const to = ids.indexOf(toId);
// A row can disappear between two clicks (a download finishing moves it
// from the queue to the done list); without both ends there is no range.
if (from < 0 || to < 0) {
return;
}
const target = this.list().get(toId)?.checked ?? false;
const start = Math.min(from, to);
const end = Math.max(from, to);
for (let i = start; i <= end; i++) {
const item = this.list().get(ids[i]);
if (item) {
item.checked = target;
}
}
}
}