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>
This commit is contained in:
Mattsson
2026-09-01 14:37:25 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 237bdd0366
commit 43341aa55c
11 changed files with 430 additions and 93 deletions
+17 -11
View File
@@ -60,6 +60,7 @@ import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatus
import AttachmentPreviewSheet from '@/components/bookkeeping/AttachmentPreviewSheet'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
@@ -257,6 +258,9 @@ export default function JournalEntryList({
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
const [showMissingOnly, setShowMissingOnly] = useState(initialShowMissingOnly)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Radix' onCheckedChange carries no mouse event: the click that precedes it
// records whether shift was held, for range selection.
const shiftHeld = useRef(false)
const [batchReason, setBatchReason] = useState('')
const [batchSubmitting, setBatchSubmitting] = useState(false)
const [bulkOpen, setBulkOpen] = useState(false)
@@ -810,15 +814,6 @@ export default function JournalEntryList({
[attachmentCounts, noDocRequired],
)
const toggleSelect = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleBatchExempt = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
@@ -1048,6 +1043,13 @@ export default function JournalEntryList({
const eligibleEntries = canWrite ? filteredEntries.filter(isEligibleForExempt) : []
const allEligibleSelected =
eligibleEntries.length > 0 && eligibleEntries.every((e) => selectedIds.has(e.id))
// Ranges walk the selectable rows of the current page, in rendered order.
const range = useRangeSelect({
visibleIds: eligibleEntries.map((e) => e.id),
selectedIds,
setSelectedIds,
})
const toggleSelect = (id: string, extend?: boolean) => range.toggle(id, extend)
const toggleSelectAll = () => {
setSelectedIds((prev) => {
const next = new Set(prev)
@@ -1058,6 +1060,7 @@ export default function JournalEntryList({
}
return next
})
range.resetAnchor()
}
// Pristine, untouched ledger: nothing posted in ANY year, no drafts, no
@@ -1537,13 +1540,16 @@ export default function JournalEntryList({
>
{/* Hover-revealed selection checkbox (concept .cb) */}
<td
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px]')}
className={cn(TD_CLASS, 'w-[26px] !pl-1 py-[9px] select-none')}
onClick={(e) => e.stopPropagation()}
>
{selectable && (
<Checkbox
checked={selectedIds.has(entry.id)}
onCheckedChange={() => toggleSelect(entry.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => toggleSelect(entry.id, shiftHeld.current)}
aria-label={t('batch_select_row')}
className={cn(
'border-foreground duration-150',
@@ -49,6 +49,7 @@ import {
import Link from 'next/link'
import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils'
import { QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks'
import { StartCard } from '@/components/dashboard/StartCard'
import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog'
@@ -1130,16 +1131,23 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
}
}, [fetchItems, selectedId, toast])
const toggleSelected = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
// Ranges walk the rendered inbox rows in order. Optimistic upload
// placeholders render no checkbox, so they stay out of the range: their
// temp-* ids are not server rows and must never reach a bulk action.
const range = useRangeSelect({
visibleIds: filteredItems.filter((item) => !item.isPlaceholder).map((item) => item.id),
selectedIds,
setSelectedIds,
})
const toggleSelected = useCallback(
(id: string, extend?: boolean) => range.toggle(id, extend),
[range],
)
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const clearSelection = useCallback(() => {
setSelectedIds(new Set())
range.resetAnchor()
}, [range])
// The selected rows, and how many of them can actually be bulk-booked
// (matched to a transaction and not yet booked). Drives the "Bokför valda"
@@ -1757,7 +1765,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
selected={item.id === selectedId}
onClick={() => handleSelect(item.id)}
isChecked={selectedIds.has(item.id)}
onToggleChecked={() => toggleSelected(item.id)}
onToggleChecked={(extend) => toggleSelected(item.id, extend)}
anyChecked={selectedIds.size > 0}
/>
))}
@@ -2124,12 +2132,15 @@ function InboxRow({
selected: boolean
onClick: () => void
isChecked: boolean
onToggleChecked: () => void
onToggleChecked: (extend?: boolean) => void
/** True when bulk-select mode is active anywhere in the list: keeps the
checkbox visible (otherwise it's hover-only on desktop). */
anyChecked: boolean
}) {
const t = useTranslations('inbox_workspace')
// Radix' onCheckedChange carries no mouse event: the preceding click records
// whether shift was held, for range selection.
const shiftHeld = useRef(false)
const amount = pickAmount(item)
const supplierName = pickSupplierName(item)
const invoiceDate = pickInvoiceDate(item)
@@ -2167,7 +2178,7 @@ function InboxRow({
{!isPlaceholder && (
<div
className={cn(
'flex items-center pl-2.5 pr-1.5 transition-opacity',
'flex select-none items-center pl-2.5 pr-1.5 transition-opacity',
// Solid on touch (pointer-coarse) or when any selection is active;
// otherwise muted-but-visible at rest. focus-within because this
// wraps the checkbox rather than being it.
@@ -2179,7 +2190,10 @@ function InboxRow({
>
<Checkbox
checked={isChecked}
onCheckedChange={onToggleChecked}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => onToggleChecked(shiftHeld.current)}
aria-label="Markera post"
className="h-3.5 w-3.5 border-foreground"
/>
@@ -1,5 +1,6 @@
'use client'
import { useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -42,7 +43,7 @@ export default function SkattekontoInboxCard({
processing: boolean
selectable?: boolean
isSelected?: boolean
onToggleSelect?: (id: string) => void
onToggleSelect?: (id: string, extend?: boolean) => void
onBokfor: (row: StoredSkattekontoTransaction) => void
onMatch: (row: StoredSkattekontoTransaction) => void
/** Optional "Ignorera" affordance: hides the row from the work list without
@@ -51,6 +52,9 @@ export default function SkattekontoInboxCard({
onIgnore?: (row: StoredSkattekontoTransaction) => void
}) {
const t = useTranslations('tx_skattekonto_card')
// See TransactionInboxCard: onCheckedChange has no event, so shift is
// captured from the preceding click.
const shiftHeld = useRef(false)
const amount = Number(row.belopp_skatteverket)
const isIncome = amount > 0
@@ -79,11 +83,14 @@ export default function SkattekontoInboxCard({
{/* Always-visible selection checkbox (concept .cb) */}
{/* Zero-width cell: the checkbox hangs in the left page margin so
the date column can sit flush with the page edge. */}
<td className={cn(TD_CLASS, 'relative w-0 !p-0')}>
<td className={cn(TD_CLASS, 'relative w-0 !p-0 select-none')}>
{selectable && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect?.(row.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => onToggleSelect?.(row.id, shiftHeld.current)}
aria-label={t('select_row')}
className={cn(
'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6',
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useDocumentExtraction } from '@/lib/hooks/use-document-extraction'
import ExtractionStatus from '@/components/ui/extraction-status'
@@ -85,7 +85,7 @@ interface TransactionInboxCardProps {
/** The company's enabled cash accounts (the page's ?enabled_only=true fetch):
* gates the move action, which is pointless with a single account. */
cashAccounts?: CashAccount[]
onToggleSelect: (id: string) => void
onToggleSelect: (id: string, extend?: boolean) => void
/** End date of the company's completed SIE-import coverage. Rows on or
* before it are pre-migration history: they most likely correspond to an
* already-imported verifikat, so the row carries a quiet marker steering
@@ -123,6 +123,10 @@ export default function TransactionInboxCard({
}: TransactionInboxCardProps) {
const t = useTranslations('tx_inbox_card')
const tMethod = useTranslations('tx_method')
// Radix' onCheckedChange carries no mouse event, so the shift state is
// captured from the click that precedes it (Radix composes our onClick
// before its own handler) and read back when the toggle fires.
const shiftHeld = useRef(false)
// Attaching underlag is a write: hide the affordance from viewers so they
// don't dead-end on a 403 (mirrors the gate in TransactionHistoryList).
const { canWrite } = useCanWrite()
@@ -288,13 +292,16 @@ export default function TransactionInboxCard({
{/* Zero-width cell: the checkbox hangs in the left page margin so
the date column can sit flush with the page edge. */}
<td
className={cn(TD_CLASS, 'relative w-0 !p-0')}
className={cn(TD_CLASS, 'relative w-0 !p-0 select-none')}
onClick={(e) => e.stopPropagation()}
>
{selectable && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleSelect(transaction.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => onToggleSelect(transaction.id, shiftHeld.current)}
aria-label="Välj transaktion"
className={cn(
'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6',