Position Table
A table for displaying open perpetual trading positions with P&L, leverage, entry/mark prices, and self-contained TP/SL and close dialogs
| TP/SL | Close | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| long | 4.2 | $682.75 | $148.32 | $162.56 | 5x | +$59.81 (+9.6%) | |||
| short | 2.8 | $530.10 | $195.20 | $189.32 | 3x | +$16.46 (+3.2%) |
import { PositionTable } from "@/components/sol/position-table";
const SOL_ICON =
"https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png";
export function PositionTableDemo() {
return (
<PositionTable
positions={[
{
symbol: "SOL",
icon: SOL_ICON,
side: "long",
size: "4.2",
value: "$682.75",
leverage: "5x",
entryPrice: "$148.32",
markPrice: "$162.56",
liquidationPrice: "$118.66",
pnl: "+$59.81",
pnlPercent: "+9.6%",
},
]}
/>
);
}Installation
pnpm dlx shadcn@latest add @solanaui/position-table
npx shadcn@latest add @solanaui/position-table
yarn dlx shadcn@latest add @solanaui/position-table
Usage
const SOL_ICON =
"https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/So11111111111111111111111111111111111111112/logo.png";
<PositionTable
positions={[
{
symbol: "SOL",
icon: SOL_ICON,
side: "long",
size: "4.2",
value: "$682.75",
leverage: "5x",
entryPrice: "$148.32",
markPrice: "$162.56",
liquidationPrice: "$118.66",
pnl: "+$59.81",
pnlPercent: "+9.6%",
},
]}
/>Each row includes a pencil icon to open a TP/SL editor dialog (OrderForm) and an X icon to open a close position dialog (ActionBox), both self-contained within the table.
With Callbacks
Wire up onEditTpSl and onClosePosition to handle user actions from the built-in dialogs.
<PositionTable
positions={positions}
onEditTpSl={(position, values) => {
console.log("TP/SL for", position.symbol, values);
}}
onClosePosition={(position) => {
console.log("Close", position.symbol, position.side);
}}
/>Source Code
"use client";import { ChevronDownIcon, ChevronUpIcon, PencilIcon, XIcon,} from "lucide-react";import React from "react";import { ActionBox } from "@/registry/sol/action-box";import { OrderForm } from "@/registry/sol/order-form";import { TokenIcon } from "@/registry/sol/token-icon";import { Button } from "@/components/ui/button";import { Dialog, DialogContent, DialogDescription, DialogTitle, DialogTrigger,} from "@/components/ui/dialog";import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table";import type { SortDirection } from "@/registry/lib/sort-utils";import { compareValues } from "@/registry/lib/sort-utils";import { cn } from "@/lib/utils";interface PositionTablePosition { symbol: string; icon: string; side: "long" | "short"; size: string; value: string; leverage: string; entryPrice: string; markPrice: string; liquidationPrice?: string; pnl: string; pnlPercent?: string; pnlTrend?: "up" | "down";}interface PositionTableProps { positions: PositionTablePosition[]; onEditTpSl?: ( position: PositionTablePosition, values: { tpPrice: string; tpPercent: string; slPrice: string; slPercent: string; }, ) => void; onClosePosition?: (position: PositionTablePosition) => void; className?: string;}const SORT_KEYS = [ "side", "symbol", "size", "value", "entryPrice", "markPrice", "leverage", "pnl",] as const;type SortKey = (typeof SORT_KEYS)[number];const SortableHeader = ({ label, sortKey, activeSortKey, sortDirection, onSort, className,}: { label: string; sortKey: SortKey; activeSortKey: SortKey | null; sortDirection: SortDirection; onSort: (key: SortKey) => void; className?: string;}) => { const isActive = activeSortKey === sortKey; return ( <TableHead className={className}> <button type="button" onClick={() => onSort(sortKey)} className={cn( "inline-flex items-center gap-1 cursor-pointer hover:text-foreground transition-colors", isActive ? "text-foreground" : "text-muted-foreground", )} > {label} {isActive && sortDirection === "asc" && ( <ChevronUpIcon className="size-3.5" /> )} {isActive && sortDirection === "desc" && ( <ChevronDownIcon className="size-3.5" /> )} </button> </TableHead> );};const PositionTable = ({ positions, onEditTpSl, onClosePosition, className,}: PositionTableProps) => { const [sortKey, setSortKey] = React.useState<SortKey | null>(null); const [sortDirection, setSortDirection] = React.useState<SortDirection>(null); const handleSort = (key: SortKey) => { if (sortKey !== key) { setSortKey(key); setSortDirection("asc"); } else if (sortDirection === "asc") { setSortDirection("desc"); } else { setSortKey(null); setSortDirection(null); } }; const sortedIndices = React.useMemo(() => { const indices = positions.map((_, i) => i); if (!sortKey || !sortDirection) return indices; return indices.sort((a, b) => { const posA = positions[a]; const posB = positions[b]; let result: number; switch (sortKey) { case "side": result = posA.side.localeCompare(posB.side); break; case "symbol": result = posA.symbol.localeCompare(posB.symbol); break; case "size": result = compareValues(posA.size, posB.size); break; case "value": result = compareValues(posA.value, posB.value); break; case "entryPrice": result = compareValues(posA.entryPrice, posB.entryPrice); break; case "markPrice": result = compareValues(posA.markPrice, posB.markPrice); break; case "leverage": result = compareValues(posA.leverage, posB.leverage); break; case "pnl": result = compareValues(posA.pnl, posB.pnl); break; default: result = 0; } return sortDirection === "desc" ? -result : result; }); }, [positions, sortKey, sortDirection]); return ( <Table className={className}> <TableHeader> <TableRow> <SortableHeader label="Type" sortKey="side" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} className="w-[80px]" /> <SortableHeader label="Asset" sortKey="symbol" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} /> <SortableHeader label="Size" sortKey="size" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} /> <SortableHeader label="Value" sortKey="value" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} /> <SortableHeader label="Entry" sortKey="entryPrice" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} /> <SortableHeader label="Mark" sortKey="markPrice" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} /> <SortableHeader label="Leverage" sortKey="leverage" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} /> <SortableHeader label="P&L" sortKey="pnl" activeSortKey={sortKey} sortDirection={sortDirection} onSort={handleSort} /> <TableHead className="w-[60px] text-muted-foreground"> TP/SL </TableHead> <TableHead className="w-[60px] text-muted-foreground"> Close </TableHead> </TableRow> </TableHeader> <TableBody> {sortedIndices.map((originalIndex) => { const position = positions[originalIndex]; const pnlTrend = position.pnlTrend ?? (position.pnl.trim().startsWith("-") ? "down" : "up"); return ( <TableRow key={`${position.symbol}-${position.side}-${originalIndex}`} > <TableCell className="w-[80px]"> <span className={cn( "text-xs font-medium uppercase", position.side === "long" ? "text-emerald-500" : "text-red-400", )} > {position.side} </span> </TableCell> <TableCell> <div className="flex items-center gap-2"> <TokenIcon src={position.icon} alt={position.symbol} width={20} height={20} /> <span className="font-medium">{position.symbol}</span> </div> </TableCell> <TableCell>{position.size}</TableCell> <TableCell>{position.value}</TableCell> <TableCell>{position.entryPrice}</TableCell> <TableCell>{position.markPrice}</TableCell> <TableCell>{position.leverage}</TableCell> <TableCell> <span className={cn( "text-sm font-medium", pnlTrend === "up" ? "text-emerald-500" : "text-red-400", )} > {position.pnl} {position.pnlPercent && ` (${position.pnlPercent})`} </span> </TableCell> <TableCell> <Dialog> <DialogTrigger asChild> <Button variant="ghost" size="icon-sm"> <PencilIcon className="size-3.5" /> </Button> </DialogTrigger> <DialogContent showCloseButton={false} className="p-0 border-none bg-transparent shadow-none sm:max-w-sm" > <DialogTitle className="sr-only"> Edit TP/SL for {position.symbol} </DialogTitle> <DialogDescription className="sr-only"> Set take profit and stop loss for this position </DialogDescription> <OrderForm entryPrice={Number.parseFloat( position.entryPrice.replace(/[$,]/g, ""), )} details={[ { label: "Size", value: position.size }, { label: "Entry Price", value: position.entryPrice }, { label: "Mark Price", value: position.markPrice }, ...(position.liquidationPrice ? [ { label: "Liquidation Price", value: position.liquidationPrice, }, ] : []), { label: "P&L", value: position.pnl }, ]} onSubmit={ onEditTpSl ? (values) => onEditTpSl(position, values) : undefined } /> </DialogContent> </Dialog> </TableCell> <TableCell> <Dialog> <DialogTrigger asChild> <Button variant="ghost" size="icon-sm"> <XIcon className="size-3.5" /> </Button> </DialogTrigger> <DialogContent className="flex items-center justify-center p-8 sm:max-w-md"> <DialogTitle className="sr-only"> Close {position.symbol} position </DialogTitle> <DialogDescription className="sr-only"> Close this position </DialogDescription> <ActionBox tokens={[ { icon: position.icon, symbol: position.symbol }, ]} defaultToken={position.symbol} label={`Close ${position.side.toUpperCase()} ${position.symbol}`} details={[ { label: "Size", value: position.size }, { label: "Entry Price", value: position.entryPrice }, { label: "Mark Price", value: position.markPrice }, { label: "P&L", value: position.pnl }, ]} submitLabel="Close Position" onSubmit={ onClosePosition ? () => onClosePosition(position) : undefined } className="border-none p-0" /> </DialogContent> </Dialog> </TableCell> </TableRow> ); })} </TableBody> </Table> );};export type { PositionTableProps, PositionTablePosition };export { PositionTable };