Files
accounted/lib/hooks/use-range-select.ts
T
Mattsson 43341aa55c feat(ui): shift-click range selection on list row checkboxes (#2117)
* feat(ui): shift-click range selection on list row checkboxes

Click one checkbox, shift-click another, and every row between them
takes the clicked row's new state, the way mail clients work. Turns a
20-row bulk selection into two clicks.

New useRangeSelect hook (lib/hooks/use-range-select.ts) keeps the anchor
and applies the range over the rows as currently rendered, so it follows
filtering, sorting and paging rather than the underlying data order. A
shift-click with no valid anchor (first click, or the anchor filtered
away) degrades to a plain toggle. Select-all and clear reset the anchor.

Wired into the 8 selection surfaces: transaction inbox and skattekonto
inbox (separate ranges, since the two row types book through different
endpoints), journal entry list, invoices, supplier invoices, orders,
pending operations, invoice inbox workspace.

Radix' onCheckedChange carries no mouse event, so each row records
shiftKey from the click that precedes it; the checkbox cells get
select-none so shift-clicking does not smear a text selection.

The pure range rule is unit tested (10 cases: both directions, range
unselect, anchor invalidation, rendered-order independence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y

* fix(ui): void the range anchor on an empty selection, keep placeholders out

Review findings from CodeRabbit and the skeptic pass, all in the new
range-selection feature:

- Clearing a selection left the anchor behind, so the next shift-click
  extended from a row the user could no longer see selected (click a row,
  press "Rensa markering", shift-click 30 rows down, get 30 rows). The
  explicit resetAnchor() calls only covered the clear paths that were
  wired by hand; several others (period change, filter change, post-bulk
  success, "Avmarkera") were not. An empty selection now counts as having
  no anchor, which covers every clear path including ones added later.
- The invoice inbox passed optimistic upload placeholders into visibleIds
  even though they render no checkbox. Safe today only because
  placeholders are always prepended; filtering them out makes the
  invariant local instead of depending on insert order elsewhere.
- pending: "Godkänn alla" pre-selects a non-empty set, so it resets the
  anchor explicitly.

Two existing tests used a fixture the UI cannot reach (an anchor with an
empty selection); they now start from the state a real anchor implies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 14:37:25 +02:00

108 lines
3.1 KiB
TypeScript

'use client'
import { useCallback, useRef } from 'react'
/**
* Gmail-style shift-click range selection for list rows.
*
* Plain click toggles one row and becomes the anchor. Shift-click applies the
* clicked row's NEW state to every row between the anchor and the target, in
* the order the rows are currently rendered (so the range follows what the
* user sees after filtering, sorting and paging, not the underlying data
* order).
*/
/**
* Pure range rule, extracted from the hook so it is testable without a
* browser. Returns the next selection.
*
* `visibleIds` must be the rendered order. When the anchor is missing (first
* click, or the anchor scrolled out of the current filter/page) a shift-click
* degrades to a plain toggle, which is what every mail client does.
*
* An EMPTY selection also counts as having no anchor: every list clears the
* selection from several places (a clear button, a filter change, a finished
* bulk action), and a range measured from a row the user can no longer see
* selected would sweep in dozens of rows they never picked. Anchoring on the
* selection rather than on the clear call sites keeps that true for clear
* paths nobody remembered to wire up.
*/
export function applyRangeSelection({
selectedIds,
visibleIds,
anchorId,
targetId,
extend,
}: {
selectedIds: Set<string>
visibleIds: string[]
anchorId: string | null
targetId: string
extend: boolean
}): Set<string> {
const next = new Set(selectedIds)
const shouldSelect = !selectedIds.has(targetId)
const anchorIndex =
anchorId === null || selectedIds.size === 0 ? -1 : visibleIds.indexOf(anchorId)
const targetIndex = visibleIds.indexOf(targetId)
if (!extend || anchorIndex === -1 || targetIndex === -1) {
if (shouldSelect) next.add(targetId)
else next.delete(targetId)
return next
}
const from = Math.min(anchorIndex, targetIndex)
const to = Math.max(anchorIndex, targetIndex)
for (let i = from; i <= to; i++) {
if (shouldSelect) next.add(visibleIds[i])
else next.delete(visibleIds[i])
}
return next
}
export interface UseRangeSelectOptions {
/** Row ids in the order they are rendered right now. */
visibleIds: string[]
selectedIds: Set<string>
setSelectedIds: (next: Set<string>) => void
}
export interface UseRangeSelect {
/** Toggle one row; pass shiftKey from the click event to extend the range. */
toggle: (id: string, extend?: boolean) => void
/** Drop the anchor, e.g. after select-all or clear. */
resetAnchor: () => void
}
export function useRangeSelect({
visibleIds,
selectedIds,
setSelectedIds,
}: UseRangeSelectOptions): UseRangeSelect {
const anchorRef = useRef<string | null>(null)
const toggle = useCallback(
(id: string, extend = false) => {
setSelectedIds(
applyRangeSelection({
selectedIds,
visibleIds,
anchorId: anchorRef.current,
targetId: id,
extend,
}),
)
anchorRef.current = id
},
[selectedIds, visibleIds, setSelectedIds],
)
const resetAnchor = useCallback(() => {
anchorRef.current = null
}, [])
return { toggle, resetAnchor }
}