diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index be32d08f..ce2b5737 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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>(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() { @@ -650,7 +660,10 @@ export default function InvoicesPage() { @@ -780,13 +793,16 @@ export default function InvoicesPage() { {/* Hover-revealed selection checkbox (supplier-invoices shape). */} {showSelection && ( e.stopPropagation()} > {isBulkSelectable(invoice) && ( 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', diff --git a/app/(dashboard)/orders/page.tsx b/app/(dashboard)/orders/page.tsx index f7f23878..7c3f12d4 100644 --- a/app/(dashboard)/orders/page.tsx +++ b/app/(dashboard)/orders/page.tsx @@ -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() { @@ -310,7 +311,10 @@ export default function OrdersPage() { @@ -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> }) { + // 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. */} - + {selectable && ( 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', diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 86ce1f5a..67c2c5ee 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -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>(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) */} e.stopPropagation()} > {canBulkSelect && ( 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', diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 56ef41c3..70db1598 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -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(null) // Payment-file bulk selection + the "already in an active betalfil" chip map. const [selectedIds, setSelectedIds] = useState>(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>(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() { @@ -450,7 +457,10 @@ export default function SupplierInvoicesPage() { @@ -579,13 +589,16 @@ export default function SupplierInvoicesPage() { {/* Hover-revealed selection checkbox (JournalEntryList shape). */} {canWrite && ( e.stopPropagation()} > {selectable && ( 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', diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 8cecb846..3298fbf6 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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', { diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index f7cc0dbd..7da00711 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -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>(new Map()) const [showMissingOnly, setShowMissingOnly] = useState(initialShowMissingOnly) const [selectedIds, setSelectedIds] = useState>(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) */} e.stopPropagation()} > {selectable && ( 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', diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 44e631c3..7e929147 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -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 && (
{ + shiftHeld.current = e.shiftKey + }} + onCheckedChange={() => onToggleChecked(shiftHeld.current)} aria-label="Markera post" className="h-3.5 w-3.5 border-foreground" /> diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx index 4b198517..4db34745 100644 --- a/components/transactions/SkattekontoInboxCard.tsx +++ b/components/transactions/SkattekontoInboxCard.tsx @@ -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. */} - + {selectable && ( 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', diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 6eb80f7c..976cfd04 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -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. */} e.stopPropagation()} > {selectable && ( 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', diff --git a/lib/hooks/__tests__/use-range-select.test.ts b/lib/hooks/__tests__/use-range-select.test.ts new file mode 100644 index 00000000..889cbed3 --- /dev/null +++ b/lib/hooks/__tests__/use-range-select.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from 'vitest' +import { applyRangeSelection } from '@/lib/hooks/use-range-select' + +const VISIBLE = ['a', 'b', 'c', 'd', 'e'] + +function sorted(ids: Set): string[] { + return [...ids].sort() +} + +describe('applyRangeSelection', () => { + it('toggles a single row when not extending', () => { + const next = applyRangeSelection({ + selectedIds: new Set(), + visibleIds: VISIBLE, + anchorId: null, + targetId: 'b', + extend: false, + }) + expect(sorted(next)).toEqual(['b']) + }) + + it('unselects a selected row when not extending', () => { + const next = applyRangeSelection({ + selectedIds: new Set(['b']), + visibleIds: VISIBLE, + anchorId: 'b', + targetId: 'b', + extend: false, + }) + expect(sorted(next)).toEqual([]) + }) + + it('selects the range downwards from the anchor', () => { + const next = applyRangeSelection({ + selectedIds: new Set(['b']), + visibleIds: VISIBLE, + anchorId: 'b', + targetId: 'd', + extend: true, + }) + expect(sorted(next)).toEqual(['b', 'c', 'd']) + }) + + it('selects the range upwards from the anchor', () => { + const next = applyRangeSelection({ + selectedIds: new Set(['d']), + visibleIds: VISIBLE, + anchorId: 'd', + targetId: 'b', + extend: true, + }) + expect(sorted(next)).toEqual(['b', 'c', 'd']) + }) + + it('unselects the whole range when the target was selected', () => { + const next = applyRangeSelection({ + selectedIds: new Set(['a', 'b', 'c', 'd']), + visibleIds: VISIBLE, + anchorId: 'a', + targetId: 'c', + extend: true, + }) + expect(sorted(next)).toEqual(['d']) + }) + + it('keeps selections outside the range untouched', () => { + const next = applyRangeSelection({ + selectedIds: new Set(['a', 'b']), + visibleIds: VISIBLE, + anchorId: 'b', + targetId: 'd', + extend: true, + }) + expect(sorted(next)).toEqual(['a', 'b', 'c', 'd']) + }) + + it('degrades to a plain toggle when there is no anchor yet', () => { + const next = applyRangeSelection({ + selectedIds: new Set(), + visibleIds: VISIBLE, + anchorId: null, + targetId: 'd', + extend: true, + }) + expect(sorted(next)).toEqual(['d']) + }) + + it('degrades to a plain toggle when the selection was cleared', () => { + // The anchor row is still on screen, but the user cleared the selection + // (clear button, filter change, finished bulk action). Extending from it + // would sweep in rows they never picked. + const next = applyRangeSelection({ + selectedIds: new Set(), + visibleIds: VISIBLE, + anchorId: 'a', + targetId: 'e', + extend: true, + }) + expect(sorted(next)).toEqual(['e']) + }) + + it('degrades to a plain toggle when the anchor is no longer visible', () => { + // The anchor row was filtered away or is on another page. + const next = applyRangeSelection({ + selectedIds: new Set(['z']), + visibleIds: VISIBLE, + anchorId: 'z', + targetId: 'c', + extend: true, + }) + expect(sorted(next)).toEqual(['c', 'z']) + }) + + it('follows the rendered order, not the id order', () => { + // The anchor is always a row the user just clicked, so it is selected. + const next = applyRangeSelection({ + selectedIds: new Set(['e']), + visibleIds: ['e', 'd', 'c', 'b', 'a'], + anchorId: 'e', + targetId: 'c', + extend: true, + }) + expect(sorted(next)).toEqual(['c', 'd', 'e']) + }) + + it('unselects just the row when anchor and target are the same', () => { + const next = applyRangeSelection({ + selectedIds: new Set(['c']), + visibleIds: VISIBLE, + anchorId: 'c', + targetId: 'c', + extend: true, + }) + expect(sorted(next)).toEqual([]) + }) +}) diff --git a/lib/hooks/use-range-select.ts b/lib/hooks/use-range-select.ts new file mode 100644 index 00000000..372957ae --- /dev/null +++ b/lib/hooks/use-range-select.ts @@ -0,0 +1,107 @@ +'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 + visibleIds: string[] + anchorId: string | null + targetId: string + extend: boolean +}): Set { + 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 + setSelectedIds: (next: Set) => 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(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 } +}