From 5f81c0638cbace0760bb90e84af9081fb7e0fbf2 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:39:34 +0200 Subject: [PATCH] fix(ui): make row selection checkboxes always visible at muted opacity (#2093) * fix(ui): make row selection checkboxes always visible at muted opacity User feedback: bulk-select checkboxes hidden until hover are "sjukt pilligt": an invisible 16px target forces a precise hover-then-aim per row, and bulk selection is a primary workflow on list pages. New CHECKBOX_REVEAL_CLASS in dry-table.tsx: rest at opacity-50, solid on row hover, focus, coarse pointers, and checked state. Applied to the 8 selection-checkbox sites (transaction inbox, skattekonto inbox, journal list, invoices, supplier invoices, orders, pending, invoice inbox workspace). Also fixes a touch bug: TransactionInboxCard and SkattekontoInboxCard lacked pointer-coarse fallback, leaving unselected checkboxes permanently invisible on touch devices. Row action controls (chevrons, quiet links) keep HOVER_REVEAL_CLASS. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y * fix(ui): meet WCAG 3:1 on resting selection checkboxes via border-foreground Skeptic finding: the checkbox primitive's border-input is ~1.4:1 against the page, so the new opacity-50 resting state composited to ~1.2:1, under the 3:1 non-text contrast minimum design.md commits to. Putting border-foreground unconditionally on the 8 reveal checkboxes lands the resting border at >=3.4:1 in every theme (light 3.41:1, dark 4.24:1, white-label palettes similar) and the hover/solid state at ~17:1. The border class lives at call sites, not in CHECKBOX_REVEAL_CLASS, because the constant is sometimes applied to a borderless wrapper (InvoiceInboxWorkspace); documented in dry-table.tsx. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015n8vUx9Nukr8mHC7CVNF7y --------- Co-authored-by: Claude Fable 5 --- app/(dashboard)/invoices/page.tsx | 6 +++--- app/(dashboard)/orders/page.tsx | 8 +++----- app/(dashboard)/pending/page.tsx | 8 ++++---- app/(dashboard)/supplier-invoices/page.tsx | 6 +++--- components/bookkeeping/JournalEntryList.tsx | 5 +++-- .../extensions/general/InvoiceInboxWorkspace.tsx | 12 ++++++++---- components/transactions/SkattekontoInboxCard.tsx | 10 ++++------ components/transactions/TransactionInboxCard.tsx | 10 ++++------ components/ui/dry-table.tsx | 16 ++++++++++++++++ 9 files changed, 48 insertions(+), 33 deletions(-) diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index 33484213..be32d08f 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -17,7 +17,7 @@ import { ToolbarSearch } from '@/components/ui/toolbar-search' 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 } from '@/components/ui/dry-table' +import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table' import { FyPicker } from '@/components/common/FyPicker' import { ContextPicker } from '@/components/common/ContextPicker' import { SplitButton, type SplitButtonOption } from '@/components/ui/split-button' @@ -789,10 +789,10 @@ export default function InvoicesPage() { onCheckedChange={() => toggleSelect(invoice.id)} aria-label={t('bulk_select_row')} className={cn( - 'transition-opacity duration-150', + 'border-foreground duration-150', selectedIds.has(invoice.id) || selectedIds.size > 0 ? 'opacity-100' - : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100', + : CHECKBOX_REVEAL_CLASS, )} /> )} diff --git a/app/(dashboard)/orders/page.tsx b/app/(dashboard)/orders/page.tsx index f429a754..f7f23878 100644 --- a/app/(dashboard)/orders/page.tsx +++ b/app/(dashboard)/orders/page.tsx @@ -19,7 +19,7 @@ import { EmptyState } from '@/components/ui/empty-state' 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 } from '@/components/ui/dry-table' +import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table' 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' @@ -510,10 +510,8 @@ function OrderRow({ onCheckedChange={() => onToggleSelect(order.id)} aria-label={t('select_order_aria', { number: order.order_number })} className={cn( - 'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6', - isSelected - ? 'opacity-100' - : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100', + 'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6', + isSelected ? 'opacity-100' : CHECKBOX_REVEAL_CLASS, )} /> )} diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index 578d004c..86ce1f5a 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -10,7 +10,7 @@ import { Checkbox } from '@/components/ui/checkbox' import { DataListEmpty, DataListLoading } from '@/components/ui/data-list' import { ContextPicker } from '@/components/common/ContextPicker' import { SegmentedControl } from '@/components/ui/segmented-control' -import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' +import { CHECKBOX_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' import { SlideOver, SlideOverContent, @@ -997,7 +997,7 @@ export default function PendingOperationsPage() { isSelected && 'bg-secondary/40', )} > - {/* Hover-revealed selection checkbox (concept .cb) */} + {/* Always-visible selection checkbox (concept .cb) */} e.stopPropagation()} @@ -1008,8 +1008,8 @@ export default function PendingOperationsPage() { onCheckedChange={() => toggleSelected(op.id)} aria-label={t('select_operation_aria')} className={cn( - 'duration-150', - isSelected ? 'opacity-100' : HOVER_REVEAL_CLASS, + 'border-foreground duration-150', + isSelected ? 'opacity-100' : CHECKBOX_REVEAL_CLASS, )} /> )} diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index b26391bd..56ef41c3 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button' 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 } from '@/components/ui/dry-table' +import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table' import { FyPicker } from '@/components/common/FyPicker' import { ContextPicker } from '@/components/common/ContextPicker' import { HelpPopover } from '@/components/ui/help-popover' @@ -588,10 +588,10 @@ export default function SupplierInvoicesPage() { onCheckedChange={() => toggleSelect(inv.id)} aria-label={t('bulk_select_row')} className={cn( - 'transition-opacity duration-150', + 'border-foreground duration-150', selectedIds.has(inv.id) || selectedIds.size > 0 ? 'opacity-100' - : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100', + : CHECKBOX_REVEAL_CLASS, )} /> )} diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 5e54a550..f7cc0dbd 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -40,6 +40,7 @@ import { VTH_CLASS, VTD_CLASS, QUIET_LINK_CLASS, + CHECKBOX_REVEAL_CLASS, RowFoldout, } from '@/components/ui/dry-table' import { ArrowDown, ArrowUp, ArrowUpDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Copy, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react' @@ -1545,10 +1546,10 @@ export default function JournalEntryList({ onCheckedChange={() => toggleSelect(entry.id)} aria-label={t('batch_select_row')} className={cn( - 'transition-opacity duration-150', + 'border-foreground duration-150', selectedIds.has(entry.id) ? 'opacity-100' - : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100', + : CHECKBOX_REVEAL_CLASS, )} /> )} diff --git a/components/extensions/general/InvoiceInboxWorkspace.tsx b/components/extensions/general/InvoiceInboxWorkspace.tsx index 6dc9c2f1..44e631c3 100644 --- a/components/extensions/general/InvoiceInboxWorkspace.tsx +++ b/components/extensions/general/InvoiceInboxWorkspace.tsx @@ -48,7 +48,7 @@ import { } from 'lucide-react' import Link from 'next/link' import { cn, formatCurrency, formatDate, formatDateLong } from '@/lib/utils' -import { QUIET_LINK_CLASS } from '@/components/ui/dry-table' +import { QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table' import { GoogleMark, MicrosoftMark } from '@/components/ui/provider-marks' import { StartCard } from '@/components/dashboard/StartCard' import EditKonteringDialog from '@/components/extensions/general/EditKonteringDialog' @@ -2168,8 +2168,12 @@ function InboxRow({
e.stopPropagation()} > @@ -2177,7 +2181,7 @@ function InboxRow({ checked={isChecked} onCheckedChange={onToggleChecked} aria-label="Markera post" - className="h-3.5 w-3.5" + className="h-3.5 w-3.5 border-foreground" />
)} diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx index e3de6f83..4b198517 100644 --- a/components/transactions/SkattekontoInboxCard.tsx +++ b/components/transactions/SkattekontoInboxCard.tsx @@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Checkbox } from '@/components/ui/checkbox' -import { TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table' +import { TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import { AlertCircle, Landmark, Link2, Loader2 } from 'lucide-react' @@ -76,7 +76,7 @@ export default function SkattekontoInboxCard({ // removal window. inert={isExiting || undefined} > - {/* Hover-revealed selection checkbox (concept .cb) */} + {/* 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. */} @@ -86,10 +86,8 @@ export default function SkattekontoInboxCard({ onCheckedChange={() => onToggleSelect?.(row.id)} aria-label={t('select_row')} className={cn( - 'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6', - isSelected - ? 'opacity-100' - : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100', + 'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6', + isSelected ? 'opacity-100' : CHECKBOX_REVEAL_CLASS, )} /> )} diff --git a/components/transactions/TransactionInboxCard.tsx b/components/transactions/TransactionInboxCard.tsx index 7c647c23..6eb80f7c 100644 --- a/components/transactions/TransactionInboxCard.tsx +++ b/components/transactions/TransactionInboxCard.tsx @@ -7,7 +7,7 @@ import ExtractionStatus from '@/components/ui/extraction-status' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' -import { TD_CLASS, RowFoldout } from '@/components/ui/dry-table' +import { TD_CLASS, CHECKBOX_REVEAL_CLASS, RowFoldout } from '@/components/ui/dry-table' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { isImportedTransaction } from '@/lib/transactions/origin' import { @@ -284,7 +284,7 @@ export default function TransactionInboxCard({ : undefined } > - {/* Hover-revealed selection checkbox (concept .cb) */} + {/* 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. */} onToggleSelect(transaction.id)} aria-label="Välj transaktion" className={cn( - 'absolute -left-5 top-1/2 -translate-y-1/2 transition-opacity duration-150 md:-left-6', - isSelected - ? 'opacity-100' - : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100', + 'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6', + isSelected ? 'opacity-100' : CHECKBOX_REVEAL_CLASS, )} /> )} diff --git a/components/ui/dry-table.tsx b/components/ui/dry-table.tsx index 6ef6a921..f9a5bc7c 100644 --- a/components/ui/dry-table.tsx +++ b/components/ui/dry-table.tsx @@ -27,6 +27,22 @@ export const QUIET_LINK_CLASS = export const HOVER_REVEAL_CLASS = 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100' +// Row SELECTION checkboxes are not hover-reveal controls: bulk-select is a +// primary workflow on list pages, and an invisible 16px target forces a +// precise hover-then-aim per row (user feedback: "sjukt pilligt"). They rest +// always-visible at muted opacity and go solid on hover/focus/coarse +// pointers; call sites force opacity-100 when checked. Action controls +// (chevrons, buttons) keep HOVER_REVEAL_CLASS above. +// +// Call sites must also put `border-foreground` (unconditionally) on the +// Checkbox itself: the primitive's border-input is ~1.4:1 against the page, +// which composited at opacity-50 lands ~1.2:1, under the WCAG 3:1 non-text +// minimum. border-foreground composites to >=3.4:1 at rest in every theme. +// It lives at the call site, not in this constant, because the constant is +// sometimes applied to a borderless WRAPPER around the checkbox. +export const CHECKBOX_REVEAL_CLASS = + 'opacity-50 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100' + // Animated row expansion (concept vwrap/vinner): grid-rows 0fr -> 1fr on // mount; the global reduced-motion rule collapses the transition. export function RowFoldout({ children }: { children: React.ReactNode }) {