first commit
This commit is contained in:
58
ui/components/table/columnConfigDropdown.tsx
Normal file
58
ui/components/table/columnConfigDropdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Columns3, RotateCcw } from "lucide-react";
|
||||
import type { ColumnConfigEntry } from "./hooks/useColumnConfig";
|
||||
|
||||
interface ColumnConfigDropdownProps {
|
||||
entries: ColumnConfigEntry[];
|
||||
labels?: Record<string, string>;
|
||||
onToggleVisibility: (columnId: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
function formatColumnId(id: string): string {
|
||||
return id
|
||||
.replace(/^metadata_/, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
export function ColumnConfigDropdown({ entries, labels = {}, onToggleVisibility, onReset }: ColumnConfigDropdownProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7.5 w-7.5" data-testid="column-config-trigger" aria-label="Column configuration">
|
||||
<Columns3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-2" align="end">
|
||||
<div className="space-y-1">
|
||||
<div className="text-muted-foreground px-1 pb-1 text-xs font-medium">Toggle Columns</div>
|
||||
{entries.map((entry) => (
|
||||
<label key={entry.id} className="hover:bg-muted/50 flex cursor-pointer items-center gap-2 rounded px-1 py-1">
|
||||
<Checkbox
|
||||
checked={entry.visible}
|
||||
onCheckedChange={() => onToggleVisibility(entry.id)}
|
||||
data-testid={`column-visibility-${entry.id}`}
|
||||
/>
|
||||
<span className="truncate text-sm">{labels[entry.id] ?? formatColumnId(entry.id)}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="border-t pt-1">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-sm"
|
||||
data-testid="column-reset-default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
94
ui/components/table/columnPinning.ts
Normal file
94
ui/components/table/columnPinning.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Column, ColumnPinningState } from "@tanstack/react-table";
|
||||
import { CSSProperties, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Measures actual header cell DOM widths and computes pixel-perfect
|
||||
* sticky offsets for pinned columns.
|
||||
*/
|
||||
export function usePinOffsets(
|
||||
headerCellRefs: React.MutableRefObject<Map<string, HTMLTableCellElement>>,
|
||||
columnPinning: ColumnPinningState,
|
||||
) {
|
||||
const [offsets, setOffsets] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Serialize pinning arrays to stable strings so the effect only fires
|
||||
// when the actual pinned column IDs change, not on every render.
|
||||
const leftKey = (columnPinning.left ?? []).join(",");
|
||||
const rightKey = (columnPinning.right ?? []).join(",");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const leftPinned = leftKey ? leftKey.split(",") : [];
|
||||
const rightPinned = rightKey ? rightKey.split(",") : [];
|
||||
const next = new Map<string, number>();
|
||||
|
||||
let left = 0;
|
||||
for (const id of leftPinned) {
|
||||
next.set(id, left);
|
||||
const el = headerCellRefs.current.get(id);
|
||||
if (el) left += el.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
let right = 0;
|
||||
for (let i = rightPinned.length - 1; i >= 0; i--) {
|
||||
const id = rightPinned[i];
|
||||
next.set(id, right);
|
||||
const el = headerCellRefs.current.get(id);
|
||||
if (el) right += el.getBoundingClientRect().width;
|
||||
}
|
||||
|
||||
// Only update if offsets actually changed to avoid infinite re-render loops
|
||||
setOffsets((prev) => {
|
||||
if (prev.size === next.size) {
|
||||
let same = true;
|
||||
for (const [k, v] of next) {
|
||||
if (prev.get(k) !== v) {
|
||||
same = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (same) return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [leftKey, rightKey, headerCellRefs]);
|
||||
|
||||
return offsets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a ref callback setter and the refs map for header cell measurement.
|
||||
*/
|
||||
export function useHeaderCellRefs() {
|
||||
const refs = useRef<Map<string, HTMLTableCellElement>>(new Map());
|
||||
|
||||
const setRef = (columnId: string) => (el: HTMLTableCellElement | null) => {
|
||||
if (el) refs.current.set(columnId, el);
|
||||
else refs.current.delete(columnId);
|
||||
};
|
||||
|
||||
return { headerCellRefs: refs, setHeaderCellRef: setRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CSS style object for a pinned column using measured offsets.
|
||||
*/
|
||||
export function buildPinStyle<T>(column: Column<T>, offsets: Map<string, number>): CSSProperties {
|
||||
const pinned = column.getIsPinned();
|
||||
if (!pinned) return {};
|
||||
const px = offsets.get(column.id) ?? 0;
|
||||
return {
|
||||
position: "sticky",
|
||||
left: pinned === "left" ? `${px}px` : undefined,
|
||||
right: pinned === "right" ? `${px}px` : undefined,
|
||||
zIndex: 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS class for the shadow on the last left-pinned or first right-pinned column.
|
||||
* Uses an `after` pseudo-element so it isn't clipped by overflow on the table container.
|
||||
*/
|
||||
export const PIN_SHADOW_LEFT =
|
||||
"after:pointer-events-none after:absolute after:top-0 after:-right-6 after:h-full after:w-6 after:shadow-[inset_6px_0_6px_-6px_rgba(0,0,0,0.15)] dark:after:shadow-[inset_6px_0_6px_-6px_rgba(0,0,0,0.5)]";
|
||||
export const PIN_SHADOW_RIGHT =
|
||||
"before:pointer-events-none before:absolute before:top-0 before:-left-6 before:h-full before:w-6 before:shadow-[inset_-6px_0_6px_-6px_rgba(0,0,0,0.15)] dark:before:shadow-[inset_-6px_0_6px_-6px_rgba(0,0,0,0.5)]";
|
||||
130
ui/components/table/draggableColumnHeader.tsx
Normal file
130
ui/components/table/draggableColumnHeader.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdownMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Header, flexRender } from "@tanstack/react-table";
|
||||
import { ArrowLeftToLine, ArrowRightToLine, Ellipsis, EyeOff, PinOff } from "lucide-react";
|
||||
import { CSSProperties, useState } from "react";
|
||||
|
||||
export const TH_CLASS = "text-foreground h-10 px-4 text-left align-middle font-medium whitespace-nowrap";
|
||||
|
||||
export function DraggableColumnHeader<TData>({
|
||||
header,
|
||||
isConfigurable,
|
||||
pinStyle,
|
||||
pinnedHeaderClassName,
|
||||
className: extraClassName,
|
||||
onHide,
|
||||
onPin,
|
||||
onDrop,
|
||||
cellRef,
|
||||
}: {
|
||||
header: Header<TData, unknown>;
|
||||
isConfigurable: boolean;
|
||||
pinStyle: CSSProperties;
|
||||
pinnedHeaderClassName?: string;
|
||||
className?: string;
|
||||
onHide: (id: string) => void;
|
||||
onPin: (id: string, position: "left" | "right") => void;
|
||||
onDrop: (draggedId: string, targetId: string) => void;
|
||||
cellRef: (el: HTMLTableCellElement | null) => void;
|
||||
}) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDropTarget, setIsDropTarget] = useState(false);
|
||||
const pinned = header.column.getIsPinned();
|
||||
|
||||
const size = header.getSize();
|
||||
return (
|
||||
<th
|
||||
ref={cellRef}
|
||||
style={{ width: size, minWidth: size, maxWidth: size, ...pinStyle }}
|
||||
className={cn(
|
||||
TH_CLASS,
|
||||
pinned && (pinnedHeaderClassName ?? "bg-card"),
|
||||
isDragging && "opacity-50",
|
||||
isDropTarget && "ring-primary ring-inset ring-1",
|
||||
isConfigurable && "cursor-grab active:cursor-grabbing",
|
||||
extraClassName,
|
||||
)}
|
||||
draggable={isConfigurable}
|
||||
onDragStart={(e) => {
|
||||
setIsDragging(true);
|
||||
e.dataTransfer.setData("text/plain", header.column.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
onDragEnd={() => setIsDragging(false)}
|
||||
onDragOver={(e) => {
|
||||
if (!isConfigurable) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setIsDropTarget(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDropTarget(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropTarget(false);
|
||||
const draggedId = e.dataTransfer.getData("text/plain");
|
||||
if (draggedId && draggedId !== header.column.id) {
|
||||
onDrop(draggedId, header.column.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div className="group/col flex items-center">
|
||||
<div className="flex-1">{flexRender(header.column.columnDef.header, header.getContext())}</div>
|
||||
{isConfigurable && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-1 shrink-0 opacity-0 transition-opacity group-hover/col:opacity-100 focus-visible:opacity-100"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
aria-label="Column actions"
|
||||
>
|
||||
<Ellipsis className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="bottom">
|
||||
<DropdownMenuItem onClick={() => onHide(header.column.id)}>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
Hide column
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{pinned === "left" ? (
|
||||
<DropdownMenuItem onClick={() => onPin(header.column.id, "left")}>
|
||||
<PinOff className="h-4 w-4" />
|
||||
Unpin
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onPin(header.column.id, "left")}>
|
||||
<ArrowLeftToLine className="h-4 w-4" />
|
||||
Pin to left
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{pinned === "right" ? (
|
||||
<DropdownMenuItem onClick={() => onPin(header.column.id, "right")}>
|
||||
<PinOff className="h-4 w-4" />
|
||||
Unpin
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => onPin(header.column.id, "right")}>
|
||||
<ArrowRightToLine className="h-4 w-4" />
|
||||
Pin to right
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
143
ui/components/table/hooks/useColumnConfig.ts
Normal file
143
ui/components/table/hooks/useColumnConfig.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { ColumnPinningState, VisibilityState } from "@tanstack/react-table";
|
||||
import { parseAsString, useQueryState } from "nuqs";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
export interface ColumnConfigEntry {
|
||||
id: string;
|
||||
visible: boolean;
|
||||
pinned: "left" | "right" | false;
|
||||
}
|
||||
|
||||
interface UseColumnConfigOptions {
|
||||
/** All available column IDs in their default order */
|
||||
columnIds: string[];
|
||||
/** URL query param name for persistence */
|
||||
paramName?: string;
|
||||
/** Columns excluded from configuration (always visible, always in position) */
|
||||
fixedColumns?: { left?: string[]; right?: string[] };
|
||||
}
|
||||
|
||||
// URL format: col1,col2:h,col3:l,col4:r
|
||||
// no suffix = visible & unpinned, :h = hidden, :l = pinned left, :r = pinned right
|
||||
function serialize(entries: ColumnConfigEntry[]): string {
|
||||
return entries
|
||||
.map((e) => {
|
||||
let flags = "";
|
||||
if (!e.visible) flags += "h";
|
||||
if (e.pinned === "left") flags += "l";
|
||||
if (e.pinned === "right") flags += "r";
|
||||
const encoded = encodeURIComponent(e.id);
|
||||
return flags ? `${encoded}:${flags}` : encoded;
|
||||
})
|
||||
.join(",");
|
||||
}
|
||||
|
||||
function deserialize(str: string): ColumnConfigEntry[] {
|
||||
if (!str) return [];
|
||||
return str.split(",").map((part) => {
|
||||
const [rawId, flags = ""] = part.split(":");
|
||||
const id = decodeURIComponent(rawId);
|
||||
return {
|
||||
id,
|
||||
visible: !flags.includes("h"),
|
||||
pinned: flags.includes("l") ? ("left" as const) : flags.includes("r") ? ("right" as const) : false,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function useColumnConfig({ columnIds, paramName = "cols", fixedColumns }: UseColumnConfigOptions) {
|
||||
const [raw, setRaw] = useQueryState(paramName, parseAsString.withDefault(""));
|
||||
|
||||
const fixedLeft = fixedColumns?.left ?? [];
|
||||
const fixedRight = fixedColumns?.right ?? [];
|
||||
const fixedSet = useMemo(() => new Set([...fixedLeft, ...fixedRight]), [fixedLeft, fixedRight]);
|
||||
|
||||
const configurableIds = useMemo(() => columnIds.filter((id) => !fixedSet.has(id)), [columnIds, fixedSet]);
|
||||
|
||||
// Merge URL config with available columns (handles added/removed columns)
|
||||
const entries = useMemo(() => {
|
||||
const parsed = deserialize(raw);
|
||||
const result: ColumnConfigEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
// Columns present in URL config that still exist
|
||||
for (const entry of parsed) {
|
||||
if (configurableIds.includes(entry.id) && !seen.has(entry.id)) {
|
||||
result.push(entry);
|
||||
seen.add(entry.id);
|
||||
}
|
||||
}
|
||||
|
||||
// New columns not yet in URL config
|
||||
for (const id of configurableIds) {
|
||||
if (!seen.has(id)) {
|
||||
result.push({ id, visible: true, pinned: false });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [raw, configurableIds]);
|
||||
|
||||
// TanStack table state
|
||||
const columnOrder = useMemo(() => [...fixedLeft, ...entries.map((e) => e.id), ...fixedRight], [entries, fixedLeft, fixedRight]);
|
||||
|
||||
const columnVisibility = useMemo(() => {
|
||||
const vis: VisibilityState = {};
|
||||
for (const entry of entries) {
|
||||
if (!entry.visible) vis[entry.id] = false;
|
||||
}
|
||||
return vis;
|
||||
}, [entries]);
|
||||
|
||||
const columnPinning = useMemo(
|
||||
(): ColumnPinningState => ({
|
||||
left: [...fixedLeft, ...entries.filter((e) => e.pinned === "left").map((e) => e.id)],
|
||||
right: [...entries.filter((e) => e.pinned === "right").map((e) => e.id), ...fixedRight],
|
||||
}),
|
||||
[entries, fixedLeft, fixedRight],
|
||||
);
|
||||
|
||||
const persist = useCallback(
|
||||
(newEntries: ColumnConfigEntry[]) => {
|
||||
const serialized = serialize(newEntries);
|
||||
setRaw(serialized || null);
|
||||
},
|
||||
[setRaw],
|
||||
);
|
||||
|
||||
const toggleVisibility = useCallback(
|
||||
(columnId: string) => {
|
||||
persist(entries.map((e) => (e.id === columnId ? { ...e, visible: !e.visible } : e)));
|
||||
},
|
||||
[entries, persist],
|
||||
);
|
||||
|
||||
const togglePin = useCallback(
|
||||
(columnId: string, position: "left" | "right") => {
|
||||
persist(entries.map((e) => (e.id === columnId ? { ...e, pinned: e.pinned === position ? false : position } : e)));
|
||||
},
|
||||
[entries, persist],
|
||||
);
|
||||
|
||||
const reorder = useCallback(
|
||||
(newEntries: ColumnConfigEntry[]) => {
|
||||
persist(newEntries);
|
||||
},
|
||||
[persist],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setRaw(null);
|
||||
}, [setRaw]);
|
||||
|
||||
return {
|
||||
entries,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
columnPinning,
|
||||
toggleVisibility,
|
||||
togglePin,
|
||||
reorder,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
5
ui/components/table/index.ts
Normal file
5
ui/components/table/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { ColumnConfigDropdown } from "./columnConfigDropdown";
|
||||
export { buildPinStyle, PIN_SHADOW_LEFT, PIN_SHADOW_RIGHT, useHeaderCellRefs, usePinOffsets } from "./columnPinning";
|
||||
export { DraggableColumnHeader, TH_CLASS } from "./draggableColumnHeader";
|
||||
export { useColumnConfig } from "./hooks/useColumnConfig";
|
||||
export type { ColumnConfigEntry } from "./hooks/useColumnConfig";
|
||||
Reference in New Issue
Block a user