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 <noreply@anthropic.com> 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 <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:
co-authored by
Claude Fable 5
parent
05dce83a2b
commit
5f81c0638c
@@ -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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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) */}
|
||||
<span
|
||||
className="w-[18px] shrink-0 pt-1.5"
|
||||
onClick={(e) => 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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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({
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center pl-2.5 pr-1.5 transition-opacity',
|
||||
// Always visible on touch (no hover), or when any selection is active.
|
||||
anyChecked ? 'opacity-100' : 'md:opacity-0 md:group-hover:opacity-100 opacity-100'
|
||||
// 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.
|
||||
anyChecked
|
||||
? 'opacity-100'
|
||||
: cn(CHECKBOX_REVEAL_CLASS, 'focus-within:opacity-100')
|
||||
)}
|
||||
onClick={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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. */}
|
||||
<td className={cn(TD_CLASS, 'relative w-0 !p-0')}>
|
||||
@@ -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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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. */}
|
||||
<td
|
||||
@@ -297,10 +297,8 @@ export default function TransactionInboxCard({
|
||||
onCheckedChange={() => 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,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
Reference in New Issue
Block a user