feat(accounts): move drag handle into the card corner, reveal on hover

The drag handle now lives in the top-right of each card and only appears
on hover. On the dashboard card it sits exactly over the account type
icon and swaps with it on hover; on the accounts list card it overlays
the top-right corner. SortableGrid passes the handle to renderItem so
each card places it itself.
This commit is contained in:
Víctor Falcón 2026-06-20 19:10:52 +02:00
parent fa4369db38
commit 0bac60774f
5 changed files with 55 additions and 21 deletions

View File

@ -9,7 +9,7 @@ import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import { formatAccountType, supportsInvestedAmount } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { useMemo, useState } from 'react';
import { type ReactNode, useMemo, useState } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
import { Button } from '../ui/button';
import { UpdateBalanceDialog } from './update-balance-dialog';
@ -34,6 +34,7 @@ interface AccountListCardProps {
onBalanceUpdated?: () => void;
linkedLoanMetrics?: LinkedLoanMetrics;
displayCurrencyCode?: string;
dragHandle?: ReactNode;
}
export function AccountListCard({
@ -42,6 +43,7 @@ export function AccountListCard({
onBalanceUpdated,
linkedLoanMetrics,
displayCurrencyCode,
dragHandle,
}: AccountListCardProps) {
const currencyCode = displayCurrencyCode ?? account.currency_code;
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
@ -134,6 +136,11 @@ export function AccountListCard({
return (
<Card className="w-full py-0">
{dragHandle && (
<span className="absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100">
{dragHandle}
</span>
)}
<CardContent className="p-4">
<div className="flex flex-col gap-4">
<div className="flex max-w-full flex-col sm:flex-row sm:items-center sm:justify-between">

View File

@ -6,10 +6,11 @@ import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { AccountWithMetrics } from '@/hooks/use-dashboard-data';
import { cn } from '@/lib/utils';
import { supportsInvestedAmount } from '@/types/account';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts';
import { AccountTypeIcon } from './account-type-icon';
import { AmountTrendIndicator } from './amount-trend-indicator';
@ -34,6 +35,7 @@ interface AccountBalanceCardProps {
onBalanceUpdated?: () => void;
linkedLoanMetrics?: LinkedLoanMetrics;
displayCurrencyCode?: string;
dragHandle?: ReactNode;
}
export function AccountBalanceCard({
@ -42,6 +44,7 @@ export function AccountBalanceCard({
onBalanceUpdated,
linkedLoanMetrics,
displayCurrencyCode,
dragHandle,
}: AccountBalanceCardProps) {
const currencyCode = displayCurrencyCode ?? account.currency_code;
const { accountMainLineColor, accountGainLineColor, mortgageLineColor } =
@ -193,11 +196,19 @@ export function AccountBalanceCard({
</span>
)}
</div>
<div className="text-xs font-medium text-muted-foreground">
<div className="relative mr-1 size-5 shrink-0">
<AccountTypeIcon
type={account.type}
className="mr-1 inline-block"
className={cn(
'transition-opacity',
dragHandle && 'group-hover:opacity-0',
)}
/>
{dragHandle && (
<span className="absolute inset-0 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100">
{dragHandle}
</span>
)}
</div>
</CardHeader>
<CardContent>

View File

@ -24,7 +24,12 @@ import type { ReactNode } from 'react';
interface SortableGridProps<T> {
items: T[];
getId: (item: T) => string;
renderItem: (item: T) => ReactNode;
/**
* Renders one item. The provided drag handle must be placed inside the
* card so it sits exactly where the card wants it (e.g. over the account
* icon); it only becomes visible on hover via the wrapper's `group`.
*/
renderItem: (item: T, dragHandle: ReactNode) => ReactNode;
onReorder: (orderedIds: string[]) => void;
className?: string;
/** Non-sortable content rendered inside the grid after the items. */
@ -78,7 +83,7 @@ export function SortableGrid<T>({
<div className={className}>
{items.map((item) => (
<SortableItem key={getId(item)} id={getId(item)}>
{renderItem(item)}
{(dragHandle) => renderItem(item, dragHandle)}
</SortableItem>
))}
{footer}
@ -88,7 +93,13 @@ export function SortableGrid<T>({
);
}
function SortableItem({ id, children }: { id: string; children: ReactNode }) {
function SortableItem({
id,
children,
}: {
id: string;
children: (dragHandle: ReactNode) => ReactNode;
}) {
const {
attributes,
listeners,
@ -99,6 +110,19 @@ function SortableItem({ id, children }: { id: string; children: ReactNode }) {
isDragging,
} = useSortable({ id });
const dragHandle = (
<button
ref={setActivatorNodeRef}
type="button"
aria-label={__('Drag to reorder')}
className="cursor-grab touch-none text-muted-foreground transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="size-5" />
</button>
);
return (
<div
ref={setNodeRef}
@ -107,19 +131,9 @@ function SortableItem({ id, children }: { id: string; children: ReactNode }) {
transition,
zIndex: isDragging ? 50 : undefined,
}}
className={cn('relative', isDragging && 'opacity-60')}
className={cn('group relative', isDragging && 'opacity-60')}
>
{children}
<button
ref={setActivatorNodeRef}
type="button"
aria-label={__('Drag to reorder')}
className="absolute top-1 left-1/2 -translate-x-1/2 cursor-grab touch-none rounded p-1.5 text-muted-foreground/40 transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
>
<GripVertical className="size-4" />
</button>
{children(dragHandle)}
</div>
);
}

View File

@ -174,9 +174,10 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) {
items={orderedAccounts}
getId={(account) => account.id}
onReorder={handleReorder}
renderItem={(account) => (
renderItem={(account, dragHandle) => (
<AccountListCard
account={account}
dragHandle={dragHandle}
loading={isLoading}
onBalanceUpdated={handleBalanceUpdated}
linkedLoanMetrics={linkedLoanMetricsMap[account.id]}

View File

@ -260,9 +260,10 @@ export default function Dashboard() {
items={orderedAccounts}
getId={(account) => account.id}
onReorder={handleReorder}
renderItem={(account) => (
renderItem={(account, dragHandle) => (
<AccountBalanceCard
account={account}
dragHandle={dragHandle}
onBalanceUpdated={refetch}
linkedLoanMetrics={
linkedLoanMetricsMap[account.id]