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:
parent
fa4369db38
commit
0bac60774f
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue