539 lines
17 KiB
TypeScript
539 lines
17 KiB
TypeScript
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
|
import { Link } from "@remix-run/react";
|
|
import { ClipboardCheckIcon, ClipboardIcon } from "lucide-react";
|
|
import React, { type ReactNode, createContext, forwardRef, useContext, useState } from "react";
|
|
import { useCopy } from "~/hooks/useCopy";
|
|
import { cn } from "~/utils/cn";
|
|
import { Popover, PopoverContent, PopoverVerticalEllipseTrigger } from "./Popover";
|
|
import { InfoIconTooltip, SimpleTooltip } from "./Tooltip";
|
|
|
|
const variants = {
|
|
bright: {
|
|
header: "bg-background-bright",
|
|
headerCell: "px-3 py-2.5 pb-3 text-sm",
|
|
cell: "group-hover/table-row:bg-background-hover group-has-[[tabindex='0']:focus]/table-row:bg-background-hover",
|
|
cellSize: "px-3 py-3",
|
|
cellText: "text-xs group-hover/table-row:text-text-bright",
|
|
stickyCell: "bg-background-bright group-hover/table-row:bg-background-hover",
|
|
menuButton:
|
|
"bg-background-bright group-hover/table-row:bg-background-hover group-hover/table-row:ring-border-bright/70 group-has-[[tabindex='0']:focus]/table-row:bg-background-hover",
|
|
menuButtonDivider: "group-hover/table-row:border-border-bright/70",
|
|
rowSelected: "bg-background-hover group-hover/table-row:bg-background-hover",
|
|
},
|
|
"bright/no-hover": {
|
|
header: "bg-transparent",
|
|
headerCell: "px-3 py-2.5 pb-3 text-sm",
|
|
cell: "group-hover/table-row:bg-transparent",
|
|
cellSize: "px-3 py-3",
|
|
cellText: "text-xs",
|
|
stickyCell: "bg-background-bright",
|
|
menuButton: "bg-background-bright",
|
|
menuButtonDivider: "",
|
|
rowSelected: "bg-background-hover",
|
|
},
|
|
dimmed: {
|
|
header: "bg-background-dimmed",
|
|
headerCell: "px-3 py-2.5 pb-3 text-sm",
|
|
cell: "group-hover/table-row:bg-background-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
|
cellSize: "px-3 py-3",
|
|
cellText: "text-xs group-hover/table-row:text-text-bright",
|
|
stickyCell: "group-hover/table-row:bg-background-bright",
|
|
menuButton:
|
|
"bg-background-dimmed group-hover/table-row:bg-background-bright group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
|
menuButtonDivider: "group-hover/table-row:border-grid-bright",
|
|
rowSelected: "bg-background-hover group-hover/table-row:bg-background-hover",
|
|
},
|
|
"compact/mono": {
|
|
header: "bg-background-dimmed",
|
|
headerCell: "px-2 py-1.5 text-sm",
|
|
cell: "group-hover/table-row:bg-background-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
|
cellSize: "px-2 py-1.5",
|
|
cellText: "text-xs font-mono group-hover/table-row:text-text-bright",
|
|
stickyCell: "group-hover/table-row:bg-background-bright",
|
|
menuButton:
|
|
"bg-background-dimmed group-hover/table-row:bg-background-bright group-hover/table-row:ring-grid-bright group-has-[[tabindex='0']:focus]/table-row:bg-background-bright",
|
|
menuButtonDivider: "group-hover/table-row:border-grid-bright",
|
|
rowSelected: "bg-background-hover group-hover/table-row:bg-background-hover",
|
|
},
|
|
} as const;
|
|
|
|
export type TableVariant = keyof typeof variants;
|
|
|
|
type TableProps = {
|
|
containerClassName?: string;
|
|
className?: string;
|
|
children: ReactNode;
|
|
fullWidth?: boolean;
|
|
showTopBorder?: boolean;
|
|
stickyHeader?: boolean;
|
|
};
|
|
|
|
// Add TableContext
|
|
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
|
|
|
|
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
|
|
(
|
|
{
|
|
className,
|
|
containerClassName,
|
|
children,
|
|
fullWidth,
|
|
variant = "dimmed",
|
|
showTopBorder = true,
|
|
stickyHeader = false,
|
|
},
|
|
ref
|
|
) => {
|
|
return (
|
|
<TableContext.Provider value={{ variant }}>
|
|
<div
|
|
className={cn(
|
|
"whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
|
stickyHeader ? "overflow-visible" : "overflow-x-auto",
|
|
showTopBorder && "border-t",
|
|
containerClassName,
|
|
fullWidth && "w-full"
|
|
)}
|
|
>
|
|
<table ref={ref} className={cn("w-full", className)}>
|
|
{children}
|
|
</table>
|
|
</div>
|
|
</TableContext.Provider>
|
|
);
|
|
}
|
|
);
|
|
|
|
type TableHeaderProps = {
|
|
className?: string;
|
|
children: ReactNode;
|
|
};
|
|
|
|
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
|
|
({ className, children }, ref) => {
|
|
const { variant } = useContext(TableContext);
|
|
return (
|
|
<thead
|
|
ref={ref}
|
|
className={cn(
|
|
"safari-only sticky top-0 z-10 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-grid-bright supports-[(-webkit-hyphens:none)]:after:content-none",
|
|
variants[variant].header,
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</thead>
|
|
);
|
|
}
|
|
);
|
|
|
|
type TableBodyProps = {
|
|
className?: string;
|
|
children?: ReactNode;
|
|
style?: React.CSSProperties;
|
|
};
|
|
|
|
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
|
({ className, children, style }, ref) => {
|
|
return (
|
|
<tbody ref={ref} className={cn("relative overflow-y-auto", className)} style={style}>
|
|
{children}
|
|
</tbody>
|
|
);
|
|
}
|
|
);
|
|
|
|
type TableRowProps = JSX.IntrinsicElements["tr"] & {
|
|
className?: string;
|
|
children: ReactNode;
|
|
disabled?: boolean;
|
|
isSelected?: boolean;
|
|
};
|
|
|
|
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
|
({ className, disabled, isSelected, children, ...props }, ref) => {
|
|
const { variant } = useContext(TableContext);
|
|
return (
|
|
<tr
|
|
ref={ref}
|
|
{...props}
|
|
className={cn(
|
|
"group/table-row relative w-full outline-hidden after:absolute after:bottom-0 after:left-3 after:right-0 after:h-px after:bg-grid-dimmed",
|
|
isSelected && variants[variant].rowSelected,
|
|
disabled && "opacity-50",
|
|
className
|
|
)}
|
|
>
|
|
{children}
|
|
</tr>
|
|
);
|
|
}
|
|
);
|
|
|
|
type TableCellBasicProps = {
|
|
className?: string;
|
|
alignment?: "left" | "center" | "right";
|
|
children?: ReactNode;
|
|
colSpan?: number;
|
|
};
|
|
|
|
type TableHeaderCellProps = TableCellBasicProps & {
|
|
hiddenLabel?: boolean;
|
|
tooltip?: ReactNode;
|
|
disableTooltipHoverableContent?: boolean;
|
|
};
|
|
|
|
export const TableHeaderCell = forwardRef<HTMLTableCellElement, TableHeaderCellProps>(
|
|
(
|
|
{
|
|
className,
|
|
alignment = "left",
|
|
children,
|
|
colSpan,
|
|
hiddenLabel = false,
|
|
tooltip,
|
|
disableTooltipHoverableContent = false,
|
|
},
|
|
ref
|
|
) => {
|
|
const { variant } = useContext(TableContext);
|
|
let alignmentClassName = "text-left";
|
|
switch (alignment) {
|
|
case "center":
|
|
alignmentClassName = "text-center";
|
|
break;
|
|
case "right":
|
|
alignmentClassName = "text-right";
|
|
break;
|
|
}
|
|
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
|
|
return (
|
|
<th
|
|
ref={ref}
|
|
scope="col"
|
|
className={cn(
|
|
"align-middle font-medium text-text-bright",
|
|
variants[variant].headerCell,
|
|
alignmentClassName,
|
|
className
|
|
)}
|
|
colSpan={colSpan}
|
|
tabIndex={-1}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{hiddenLabel ? (
|
|
<span className="sr-only">{children}</span>
|
|
) : tooltip ? (
|
|
<div
|
|
className={cn("flex items-center gap-1", {
|
|
"justify-center": alignment === "center",
|
|
"justify-end": alignment === "right",
|
|
})}
|
|
>
|
|
{children}
|
|
<InfoIconTooltip
|
|
content={tooltip}
|
|
contentClassName="normal-case tracking-normal"
|
|
enabled={isHovered}
|
|
disableHoverableContent={disableTooltipHoverableContent}
|
|
/>
|
|
</div>
|
|
) : (
|
|
children
|
|
)}
|
|
</th>
|
|
);
|
|
}
|
|
);
|
|
|
|
type TableCellProps = TableCellBasicProps & {
|
|
to?: string;
|
|
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
hasAction?: boolean;
|
|
isSticky?: boolean;
|
|
actionClassName?: string;
|
|
rowHoverStyle?: string;
|
|
isSelected?: boolean;
|
|
isTabbableCell?: boolean;
|
|
children?: ReactNode;
|
|
style?: React.CSSProperties;
|
|
};
|
|
|
|
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
|
(
|
|
{
|
|
className,
|
|
actionClassName,
|
|
alignment = "left",
|
|
children,
|
|
colSpan,
|
|
to,
|
|
onClick,
|
|
hasAction = false,
|
|
isSticky = false,
|
|
isSelected,
|
|
isTabbableCell = false,
|
|
style,
|
|
},
|
|
ref
|
|
) => {
|
|
let alignmentClassName = "text-left";
|
|
switch (alignment) {
|
|
case "center":
|
|
alignmentClassName = "text-center";
|
|
break;
|
|
case "right":
|
|
alignmentClassName = "text-right";
|
|
break;
|
|
}
|
|
|
|
const { variant } = useContext(TableContext);
|
|
const flexClasses = cn(
|
|
"flex w-full whitespace-nowrap items-center text-text-dimmed",
|
|
variants[variant].cellSize,
|
|
variants[variant].cellText,
|
|
alignment === "left"
|
|
? "justify-start text-left"
|
|
: alignment === "center"
|
|
? "justify-center text-center"
|
|
: "justify-end text-right"
|
|
);
|
|
|
|
return (
|
|
<td
|
|
ref={ref}
|
|
className={cn(
|
|
"safari-only text-xs text-text-dimmed has-[[tabindex='0']:focus]:before:absolute has-[[tabindex='0']:focus]:before:-top-px has-[[tabindex='0']:focus]:before:left-0 has-[[tabindex='0']:focus]:before:h-px has-[[tabindex='0']:focus]:before:w-3 has-[[tabindex='0']:focus]:before:bg-grid-dimmed has-[[tabindex='0']:focus]:after:absolute has-[[tabindex='0']:focus]:after:bottom-0 has-[[tabindex='0']:focus]:after:left-0 has-[[tabindex='0']:focus]:after:right-0 has-[[tabindex='0']:focus]:after:h-px has-[[tabindex='0']:focus]:after:bg-grid-dimmed",
|
|
variants[variant].cellText,
|
|
variants[variant].cell,
|
|
to || onClick || hasAction
|
|
? "cursor-pointer"
|
|
: cn("cursor-default align-middle", variants[variant].cellSize),
|
|
!to && !onClick && alignmentClassName,
|
|
isSticky && "[&:has([data-hidden-buttons])]:w-auto sticky right-0 bg-background-dimmed",
|
|
isSticky && variants[variant].stickyCell,
|
|
isSelected && variants[variant].rowSelected,
|
|
!isSelected &&
|
|
"group-hover/table-row:before:absolute group-hover/table-row:before:left-0 group-hover/table-row:before:-top-px group-hover/table-row:before:h-px group-hover/table-row:before:w-3 group-hover/table-row:before:bg-background-hover group-hover/table-row:after:absolute group-hover/table-row:after:bottom-0 group-hover/table-row:after:left-0 group-hover/table-row:after:h-px group-hover/table-row:after:w-3 group-hover/table-row:after:bg-background-hover group-focus-visible/table-row:bg-background-bright",
|
|
className
|
|
)}
|
|
colSpan={colSpan}
|
|
style={style}
|
|
>
|
|
{to ? (
|
|
<Link
|
|
to={to}
|
|
className={cn("cursor-pointer focus:outline-hidden", flexClasses, actionClassName)}
|
|
tabIndex={isTabbableCell ? 0 : -1}
|
|
>
|
|
{children}
|
|
</Link>
|
|
) : onClick ? (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn("cursor-pointer focus:outline-hidden", flexClasses, actionClassName)}
|
|
tabIndex={isTabbableCell ? 0 : -1}
|
|
>
|
|
{children}
|
|
</button>
|
|
) : (
|
|
<>{children}</>
|
|
)}
|
|
</td>
|
|
);
|
|
}
|
|
);
|
|
|
|
type CopyableTableCellProps = TableCellProps & {
|
|
value: string;
|
|
};
|
|
|
|
export const CopyableTableCell = forwardRef<HTMLTableCellElement, CopyableTableCellProps>(
|
|
({ value, children, className, ...props }, ref) => {
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const { copy, copied } = useCopy(value);
|
|
|
|
return (
|
|
<TableCell ref={ref} className={className} {...props}>
|
|
<div
|
|
className="relative flex items-center"
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
>
|
|
{children}
|
|
{isHovered && (
|
|
<span
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
copy();
|
|
}}
|
|
className="absolute -right-2 top-1/2 z-10 flex -translate-y-1/2 cursor-pointer"
|
|
>
|
|
<SimpleTooltip
|
|
button={
|
|
<span
|
|
className={cn(
|
|
"flex size-6 items-center justify-center rounded border border-border-bright bg-background-hover",
|
|
copied
|
|
? "text-green-500"
|
|
: "text-text-dimmed hover:border-border-bright hover:bg-background-raised hover:text-text-bright"
|
|
)}
|
|
>
|
|
{copied ? (
|
|
<ClipboardCheckIcon className="size-3.5" />
|
|
) : (
|
|
<ClipboardIcon className="size-3.5" />
|
|
)}
|
|
</span>
|
|
}
|
|
content={copied ? "Copied!" : "Copy"}
|
|
disableHoverableContent
|
|
/>
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
);
|
|
}
|
|
);
|
|
|
|
export const TableCellChevron = forwardRef<
|
|
HTMLTableCellElement,
|
|
{
|
|
className?: string;
|
|
to?: string;
|
|
children?: ReactNode;
|
|
isSticky?: boolean;
|
|
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
}
|
|
>(({ className, to, children, isSticky, onClick }, ref) => {
|
|
return (
|
|
<TableCell
|
|
className={className}
|
|
isSticky={isSticky}
|
|
to={to}
|
|
onClick={onClick}
|
|
ref={ref}
|
|
alignment="right"
|
|
>
|
|
{children}
|
|
<ChevronRightIcon className="size-4 text-text-dimmed transition group-hover:text-text-bright" />
|
|
</TableCell>
|
|
);
|
|
});
|
|
|
|
export const TableCellMenu = forwardRef<
|
|
HTMLTableCellElement,
|
|
TableCellProps & {
|
|
className?: string;
|
|
isSticky?: boolean;
|
|
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
|
visibleButtons?: ReactNode;
|
|
hiddenButtons?: ReactNode;
|
|
popoverContent?: ReactNode | ((close: () => void) => ReactNode);
|
|
children?: ReactNode;
|
|
isSelected?: boolean;
|
|
}
|
|
>(
|
|
(
|
|
{
|
|
className,
|
|
isSticky,
|
|
onClick,
|
|
visibleButtons,
|
|
hiddenButtons,
|
|
popoverContent,
|
|
children,
|
|
isSelected,
|
|
},
|
|
ref
|
|
) => {
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const { variant } = useContext(TableContext);
|
|
const resolvedContent =
|
|
typeof popoverContent === "function"
|
|
? popoverContent(() => setIsOpen(false))
|
|
: popoverContent;
|
|
|
|
return (
|
|
<TableCell
|
|
className={className}
|
|
isSticky={isSticky}
|
|
onClick={onClick}
|
|
ref={ref}
|
|
alignment="right"
|
|
hasAction={true}
|
|
isSelected={isSelected}
|
|
>
|
|
<div className="relative h-full p-1">
|
|
<div
|
|
className={cn(
|
|
"absolute right-0 top-1/2 mr-1 flex -translate-y-1/2 items-center justify-end gap-0.5 rounded-[0.25rem] p-0.5 group-hover/table-row:ring-1",
|
|
variants[variant].menuButton
|
|
)}
|
|
>
|
|
{/* Hidden buttons that show on hover */}
|
|
{hiddenButtons && (
|
|
<div
|
|
data-hidden-buttons
|
|
className={cn(
|
|
"hidden group-hover/table-row:block",
|
|
popoverContent && "pr-0.5 group-hover/table-row:border-r",
|
|
variants[variant].menuButtonDivider
|
|
)}
|
|
>
|
|
<div className={cn("flex items-center gap-x-0.5 divide-x divide-grid-bright")}>
|
|
{hiddenButtons}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* Always visible buttons */}
|
|
{visibleButtons}
|
|
{/* Always visible popover with ellipsis trigger */}
|
|
{resolvedContent && (
|
|
<Popover open={isOpen} onOpenChange={(open) => setIsOpen(open)}>
|
|
<PopoverVerticalEllipseTrigger
|
|
isOpen={isOpen}
|
|
className="duration-0 group-hover/table-row:text-text-bright"
|
|
/>
|
|
<PopoverContent
|
|
className="min-w-40 max-w-80 overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
|
align="end"
|
|
>
|
|
{typeof popoverContent === "function" ? (
|
|
resolvedContent
|
|
) : (
|
|
<div className="flex flex-col gap-1 p-1">{resolvedContent}</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
);
|
|
}
|
|
);
|
|
|
|
type TableBlankRowProps = {
|
|
className?: string;
|
|
colSpan: number;
|
|
children?: ReactNode;
|
|
};
|
|
|
|
export const TableBlankRow = forwardRef<HTMLTableRowElement, TableBlankRowProps>(
|
|
({ children, colSpan, className }, ref) => {
|
|
return (
|
|
<tr ref={ref}>
|
|
<td colSpan={colSpan} className={cn("py-6 text-center text-sm", className)}>
|
|
{children}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
);
|