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
+28 -12
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useMemo } from 'react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import dynamic from 'next/dynamic'
import Link from 'next/link'
@@ -18,6 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
import { SplitButton, type SplitButtonOption } from '@/components/ui/split-button'
@@ -204,6 +205,9 @@ export default function InvoicesPage() {
const accountingMethod: string = companySettings?.accounting_method ?? 'accrual'
const deferInvoiceBooking: boolean = companySettings?.defer_invoice_booking ?? false
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Radix' onCheckedChange carries no mouse event: the preceding click records
// whether shift was held, for range selection.
const shiftHeld = useRef(false)
const [showBulkBookConfirm, setShowBulkBookConfirm] = useState(false)
const [isBulkBooking, setIsBulkBooking] = useState(false)
const [isLoading, setIsLoading] = useState(true)
@@ -419,13 +423,16 @@ export default function InvoicesPage() {
const allSelectableSelected =
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
function toggleSelect(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 selectable rows that are actually rendered: the list is
// sorted and cut at visibleCount, so rows below the fold are not in range.
const range = useRangeSelect({
visibleIds: visibleInvoices.filter(isBulkSelectable).map((inv) => inv.id),
selectedIds,
setSelectedIds,
})
function toggleSelect(id: string, extend?: boolean) {
range.toggle(id, extend)
}
const selectedInvoices = invoices.filter((inv) => selectedIds.has(inv.id))
@@ -642,7 +649,10 @@ export default function InvoicesPage() {
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))}
onClick={() => {
setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))
range.resetAnchor()
}}
>
{t('bulk_select_all', { count: selectableInvoices.length })}
</button>
@@ -650,7 +660,10 @@ export default function InvoicesPage() {
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set())}
onClick={() => {
setSelectedIds(new Set())
range.resetAnchor()
}}
>
{t('bulk_clear')}
</button>
@@ -780,13 +793,16 @@ export default function InvoicesPage() {
{/* Hover-revealed selection checkbox (supplier-invoices shape). */}
{showSelection && (
<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()}
>
{isBulkSelectable(invoice) && (
<Checkbox
checked={selectedIds.has(invoice.id)}
onCheckedChange={() => toggleSelect(invoice.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => toggleSelect(invoice.id, shiftHeld.current)}
aria-label={t('bulk_select_row')}
className={cn(
'border-foreground duration-150',
+24 -14
View File
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
@@ -20,6 +20,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { ContextPicker } from '@/components/common/ContextPicker'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -199,14 +200,11 @@ export default function OrdersPage() {
.filter((o) => isBulkBookable(o, canWrite))
.map((o) => o.id)
const toggleSelect = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const range = useRangeSelect({ visibleIds: selectableIds, selectedIds, setSelectedIds })
const toggleSelect = useCallback(
(id: string, extend?: boolean) => range.toggle(id, extend),
[range],
)
const openBulkBooking = useCallback(() => {
setBulkOrders(rows.filter((o) => selectedIds.has(o.id)))
@@ -302,7 +300,10 @@ export default function OrdersPage() {
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set(selectableIds))}
onClick={() => {
setSelectedIds(new Set(selectableIds))
range.resetAnchor()
}}
>
{t('bulk_select_all', { count: selectableIds.length })}
</button>
@@ -310,7 +311,10 @@ export default function OrdersPage() {
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set())}
onClick={() => {
setSelectedIds(new Set())
range.resetAnchor()
}}
>
{t('bulk_clear')}
</button>
@@ -470,13 +474,16 @@ function OrderRow({
canWrite: boolean
selectable: boolean
isSelected: boolean
onToggleSelect: (id: string) => void
onToggleSelect: (id: string, extend?: boolean) => void
onBook: () => void
onInvoice: () => void
onMarkBooked: () => void
onUnmark: () => void
t: ReturnType<typeof useTranslations<'webshop_orders'>>
}) {
// Radix' onCheckedChange carries no mouse event: the preceding click records
// whether shift was held, for range selection.
const shiftHeld = useRef(false)
const isRefund = order.row_type === 'refund'
const booked = order.journal_entry_id !== null
const invoiced = order.invoice_id !== null
@@ -503,11 +510,14 @@ function OrderRow({
{/* Hover-revealed selection checkbox (transactions-page pattern):
zero-width cell, the checkbox hangs in the left page margin so the
date column stays where it was. Selected rows keep it visible. */}
<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(order.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => onToggleSelect(order.id, shiftHeld.current)}
aria-label={t('select_order_aria', { number: order.order_number })}
className={cn(
'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6',
+21 -9
View File
@@ -11,6 +11,7 @@ import { DataListEmpty, DataListLoading } from '@/components/ui/data-list'
import { ContextPicker } from '@/components/common/ContextPicker'
import { SegmentedControl } from '@/components/ui/segmented-control'
import { CHECKBOX_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import {
SlideOver,
SlideOverContent,
@@ -277,6 +278,9 @@ export default function PendingOperationsPage() {
const [showCommitDialog, setShowCommitDialog] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Radix' onCheckedChange carries no mouse event: the preceding click records
// whether shift was held, for range selection.
const shiftHeld = useRef(false)
const [showBulkDialog, setShowBulkDialog] = useState(false)
const [isBulkCommitting, setIsBulkCommitting] = useState(false)
// Reject dialog state: separate from the generic destructive-confirm so we
@@ -643,13 +647,15 @@ export default function PendingOperationsPage() {
const pendingTotal = filteredOperations.filter((op) => op.status === 'pending').length
const excludedFromBulk = pendingTotal - bulkEligible.length
function toggleSelected(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 bulk-eligible operations in rendered order.
const range = useRangeSelect({
visibleIds: bulkEligibleIds,
selectedIds,
setSelectedIds,
})
function toggleSelected(id: string, extend?: boolean) {
range.toggle(id, extend)
}
function toggleSelectAll() {
@@ -658,6 +664,7 @@ export default function PendingOperationsPage() {
} else {
setSelectedIds(new Set(bulkEligibleIds))
}
range.resetAnchor()
}
// "Approve all of this type": find ops with the same operation_type that are bulk-eligible
@@ -666,6 +673,7 @@ export default function PendingOperationsPage() {
.filter((op) => op.operation_type === operationType)
.map((op) => op.id)
setSelectedIds(new Set(ids))
range.resetAnchor()
}
// Group counts for type-quick-action buttons (only show if 2+ of same type pending)
@@ -725,6 +733,7 @@ export default function PendingOperationsPage() {
disabled={isBulkCommitting || isRejecting}
onClick={() => {
setSelectedIds(new Set(bulkEligibleIds))
range.resetAnchor()
setShowBulkDialog(true)
}}
>
@@ -999,13 +1008,16 @@ export default function PendingOperationsPage() {
>
{/* Always-visible selection checkbox (concept .cb) */}
<span
className="w-[18px] shrink-0 pt-1.5"
className="w-[18px] shrink-0 select-none pt-1.5"
onClick={(e) => e.stopPropagation()}
>
{canBulkSelect && (
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelected(op.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => toggleSelected(op.id, shiftHeld.current)}
aria-label={t('select_operation_aria')}
className={cn(
'border-foreground duration-150',
+26 -13
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTranslations } from 'next-intl'
@@ -11,6 +11,7 @@ import { Checkbox } from '@/components/ui/checkbox'
import { ToolbarSearch } from '@/components/ui/toolbar-search'
import { DataListEmpty } from '@/components/ui/data-list'
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { FyPicker } from '@/components/common/FyPicker'
import { ContextPicker } from '@/components/common/ContextPicker'
import { HelpPopover } from '@/components/ui/help-popover'
@@ -163,6 +164,9 @@ export default function SupplierInvoicesPage() {
const [approvingId, setApprovingId] = useState<string | null>(null)
// Payment-file bulk selection + the "already in an active betalfil" chip map.
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// Radix' onCheckedChange carries no mouse event: the preceding click records
// whether shift was held, for range selection.
const shiftHeld = useRef(false)
const [activeBatchInvoiceIds, setActiveBatchInvoiceIds] = useState<Set<string>>(new Set())
const [showPaymentDialog, setShowPaymentDialog] = useState(false)
@@ -294,13 +298,15 @@ export default function SupplierInvoicesPage() {
const allSelectableSelected =
selectableInvoices.length > 0 && selectableInvoices.every((inv) => selectedIds.has(inv.id))
function toggleSelect(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 selectable rows in rendered (sorted) order.
const range = useRangeSelect({
visibleIds: sortedInvoices.filter(isBatchSelectable).map((inv) => inv.id),
selectedIds,
setSelectedIds,
})
function toggleSelect(id: string, extend?: boolean) {
range.toggle(id, extend)
}
// Labels the excluded rows in the payment dialog ("Derome CD3014794407"),
@@ -440,9 +446,10 @@ export default function SupplierInvoicesPage() {
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() =>
onClick={() => {
setSelectedIds(new Set(selectableInvoices.map((inv) => inv.id)))
}
range.resetAnchor()
}}
>
{t('bulk_select_all', { count: selectableInvoices.length })}
</button>
@@ -450,7 +457,10 @@ export default function SupplierInvoicesPage() {
<button
type="button"
className={QUIET_LINK_CLASS}
onClick={() => setSelectedIds(new Set())}
onClick={() => {
setSelectedIds(new Set())
range.resetAnchor()
}}
>
{t('bulk_clear')}
</button>
@@ -579,13 +589,16 @@ export default function SupplierInvoicesPage() {
{/* Hover-revealed selection checkbox (JournalEntryList shape). */}
{canWrite && (
<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(inv.id)}
onCheckedChange={() => toggleSelect(inv.id)}
onClick={(e) => {
shiftHeld.current = e.shiftKey
}}
onCheckedChange={() => toggleSelect(inv.id, shiftHeld.current)}
aria-label={t('bulk_select_row')}
className={cn(
'border-foreground duration-150',
+23 -14
View File
@@ -71,6 +71,7 @@ import { useCompany } from '@/contexts/CompanyContext'
import { useCashAccounts } from '@/lib/reference-data/hooks'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { useRealtimeSupabase } from '@/lib/hooks/use-realtime-supabase'
import { useRangeSelect } from '@/lib/hooks/use-range-select'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { roundOre } from '@/lib/money'
@@ -845,6 +846,20 @@ export default function TransactionsPage() {
[exitingIds, inboxItems],
)
// Shift-click ranges run per selection set: bank rows and skattekonto rows
// are interleaved in one table but are booked through different endpoints,
// so each range walks only its own selectable ids in rendered order.
const bankRange = useRangeSelect({
visibleIds: selectableInboxIds,
selectedIds,
setSelectedIds,
})
const skvRange = useRangeSelect({
visibleIds: selectableSkvIds,
selectedIds: skvSelectedIds,
setSelectedIds: setSkvSelectedIds,
})
const skvSelectedRows = useMemo(
() => skvRows.filter((r) => skvSelectedIds.has(r.id)),
[skvRows, skvSelectedIds],
@@ -2734,13 +2749,8 @@ export default function TransactionsPage() {
})
}
function toggleSkvSelect(id: string) {
setSkvSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
function toggleSkvSelect(id: string, extend?: boolean) {
skvRange.toggle(id, extend)
}
async function handleSkvUnignore(id: string) {
@@ -3041,18 +3051,15 @@ export default function TransactionsPage() {
// The bulkbar counter ticks per completed row.
const BATCH_CONCURRENCY = 5
function toggleBatchSelect(id: string) {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
function toggleBatchSelect(id: string, extend?: boolean) {
bankRange.toggle(id, extend)
}
function exitBatchMode() {
setSelectedIds(new Set())
setSkvSelectedIds(new Set())
bankRange.resetAnchor()
skvRange.resetAnchor()
}
async function handleBatchDelete() {
@@ -3810,6 +3817,8 @@ export default function TransactionsPage() {
onClick={() => {
setSelectedIds(new Set(selectableInboxIds))
setSkvSelectedIds(new Set(selectableSkvIds))
bankRange.resetAnchor()
skvRange.resetAnchor()
}}
>
{t('batch_select_all', {