feat: update dependencies and enhance UI components
- Added new dependencies for drag-and-drop functionality with @dnd-kit packages. - Updated package versions for @tanstack/react-virtual and date-fns. - Refactored AppShell component to utilize AppSidebar and SiteHeader for improved layout. - Enhanced Frame component with new theming capabilities and improved structure. - Introduced filtering capabilities in Agents and Lists pages with new UI elements. - Added new utility functions for authentication claims management. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
[
|
||||
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
|
||||
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
|
||||
"rounded-lg",
|
||||
"px-3",
|
||||
"py-2.5",
|
||||
"has-[>svg]:gap-x-2.5",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
|
||||
info: "border-info/30 bg-info/4 [&>svg]:text-info",
|
||||
success: "border-success/30 bg-success/4 [&>svg]:text-success",
|
||||
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
|
||||
invert:
|
||||
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn(
|
||||
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground",
|
||||
outline: "border-border bg-transparent dark:bg-input/32",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
info: "bg-info text-white",
|
||||
success: "bg-success text-white",
|
||||
warning: "bg-warning text-white",
|
||||
destructive: "bg-destructive text-white",
|
||||
focus: "bg-focus text-focus-foreground",
|
||||
invert: "bg-invert text-invert-foreground",
|
||||
"primary-light":
|
||||
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
|
||||
"warning-light":
|
||||
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
|
||||
"success-light":
|
||||
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
|
||||
"info-light":
|
||||
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
|
||||
"destructive-light":
|
||||
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
|
||||
"invert-light":
|
||||
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
|
||||
"focus-light":
|
||||
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
|
||||
"primary-outline":
|
||||
"bg-background border-border text-primary dark:bg-input/30",
|
||||
"warning-outline":
|
||||
"bg-background border-border text-warning-foreground dark:bg-input/30",
|
||||
"success-outline":
|
||||
"bg-background border-border text-success-foreground dark:bg-input/30",
|
||||
"info-outline":
|
||||
"bg-background border-border text-info-foreground dark:bg-input/30",
|
||||
"destructive-outline":
|
||||
"bg-background border-border text-destructive-foreground dark:bg-input/30",
|
||||
"invert-outline":
|
||||
"bg-background border-border text-invert-foreground dark:bg-input/30",
|
||||
"focus-outline":
|
||||
"bg-background border-border text-focus-foreground dark:bg-input/30",
|
||||
},
|
||||
size: {
|
||||
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
|
||||
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
|
||||
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
|
||||
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
|
||||
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
|
||||
},
|
||||
/** `default`: active style radius. `full`: pill radius. */
|
||||
radius: {
|
||||
default:
|
||||
"rounded-sm",
|
||||
full: "rounded-full",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface BadgeProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof badgeVariants>["variant"]
|
||||
size?: VariantProps<typeof badgeVariants>["size"]
|
||||
radius?: VariantProps<typeof badgeVariants>["radius"]
|
||||
}
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
radius,
|
||||
render,
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "badge",
|
||||
className: cn(badgeVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants, type BadgeProps }
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import { Input } from "@evofw/ui/components/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@evofw/ui/components/popover"
|
||||
import { Separator } from "@evofw/ui/components/separator"
|
||||
import { CirclePlusIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
function DataGridColumnFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataGridColumnFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const filterValue = column?.getFilterValue()
|
||||
const selectedValues = new Set(
|
||||
Array.isArray(filterValue) ? (filterValue as string[]) : []
|
||||
)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchQuery) return options
|
||||
return options.filter((option) =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
}, [options, searchQuery])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="outline" size="sm">
|
||||
<CirclePlusIcon className="size-4" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="mx-2 h-4" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge variant="secondary" className="px-1 font-normal">
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="px-1 font-normal"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={title}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{filteredOptions.length === 0 ? (
|
||||
<div className="text-muted-foreground py-6 text-center text-sm">
|
||||
No results found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{filteredOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
const facetCount = facets?.get(option.value)
|
||||
const toggleOption = () => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={isSelected}
|
||||
onClick={toggleOption}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
toggleOption()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md relative flex cursor-pointer items-center gap-2 px-2 py-1.5 text-sm outline-hidden select-none",
|
||||
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary rounded-sm flex h-4 w-4 items-center justify-center border",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facetCount !== undefined && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facetCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
column?.setFilterValue(undefined)
|
||||
}
|
||||
}}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground rounded-md relative flex cursor-pointer items-center justify-center px-2 py-1.5 text-sm outline-hidden select-none"
|
||||
>
|
||||
Clear filters
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnFilter, type DataGridColumnFilterProps }
|
||||
@@ -0,0 +1,355 @@
|
||||
"use no memo"
|
||||
|
||||
import { HTMLAttributes, memo, ReactNode, useMemo } from "react"
|
||||
import {
|
||||
getColumnHeaderLabel,
|
||||
useDataGrid,
|
||||
} from "@/components/reui/data-grid/data-grid"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evofw/ui/components/dropdown-menu"
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
|
||||
|
||||
interface DataGridColumnHeaderProps<
|
||||
TData,
|
||||
TValue,
|
||||
> extends HTMLAttributes<HTMLDivElement> {
|
||||
column: Column<TData, TValue>
|
||||
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
/** Reserved; pin controls are gated by tableLayout.columnsPinnable + column.getCanPin(). */
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
// TanStack's columnOrder defaults to [] until a consumer seeds it; fall
|
||||
// back to the definition order so Move Left/Right work out of the box.
|
||||
const columnOrderState = table.getState().columnOrder
|
||||
const columnOrder =
|
||||
columnOrderState.length > 0
|
||||
? columnOrderState
|
||||
: table.getAllLeafColumns().map((leafColumn) => leafColumn.id)
|
||||
const columnVisibilityKey =
|
||||
props.tableLayout?.columnsVisibility && visibility
|
||||
? JSON.stringify(table.getState().columnVisibility)
|
||||
: ""
|
||||
const isSorted = column.getIsSorted()
|
||||
const isPinned = column.getIsPinned()
|
||||
const canSort = column.getCanSort()
|
||||
const canPin = column.getCanPin()
|
||||
const canResize = column.getCanResize()
|
||||
|
||||
const columnIndex = columnOrder.indexOf(column.id)
|
||||
const canMoveLeft = columnIndex > 0
|
||||
const canMoveRight = columnIndex < columnOrder.length - 1
|
||||
|
||||
const handleSort = () => {
|
||||
if (isSorted === "asc") {
|
||||
column.toggleSorting(true)
|
||||
} else if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const headerLabelClassName = cn(
|
||||
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
|
||||
className
|
||||
)
|
||||
|
||||
const headerButtonClassName = cn(
|
||||
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" aria-hidden="true" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" aria-hidden="true" />
|
||||
))
|
||||
|
||||
const hasControls =
|
||||
props.tableLayout?.columnsMovable ||
|
||||
(props.tableLayout?.columnsVisibility && visibility) ||
|
||||
(props.tableLayout?.columnsPinnable && canPin) ||
|
||||
filter
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: ReactNode[] = []
|
||||
let hasPreviousSection = false
|
||||
|
||||
// Filter section
|
||||
if (filter) {
|
||||
items.push(
|
||||
<DropdownMenuGroup key="group-filter">
|
||||
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Sort section
|
||||
if (canSort) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-sort" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="sort-asc"
|
||||
onClick={() => {
|
||||
if (isSorted === "asc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(false)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowUpIcon className="size-3.5!" />
|
||||
<span className="grow">Asc</span>
|
||||
{isSorted === "asc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="sort-desc"
|
||||
onClick={() => {
|
||||
if (isSorted === "desc") {
|
||||
column.clearSorting()
|
||||
} else {
|
||||
column.toggleSorting(true)
|
||||
}
|
||||
}}
|
||||
disabled={!canSort}
|
||||
>
|
||||
<ArrowDownIcon className="size-3.5!" />
|
||||
<span className="grow">Desc</span>
|
||||
{isSorted === "desc" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Pin section
|
||||
if (props.tableLayout?.columnsPinnable && canPin) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-pin" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="pin-left"
|
||||
onClick={() => column.pin(isPinned === "left" ? false : "left")}
|
||||
>
|
||||
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to left</span>
|
||||
{isPinned === "left" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="pin-right"
|
||||
onClick={() => column.pin(isPinned === "right" ? false : "right")}
|
||||
>
|
||||
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span className="grow">Pin to right</span>
|
||||
{isPinned === "right" && (
|
||||
<CheckIcon className="text-primary size-4 opacity-100!" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Move section
|
||||
if (props.tableLayout?.columnsMovable) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-move" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuItem
|
||||
key="move-left"
|
||||
onClick={() => {
|
||||
if (columnIndex > 0) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex - 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveLeft || isPinned !== false}
|
||||
>
|
||||
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Left</span>
|
||||
</DropdownMenuItem>,
|
||||
<DropdownMenuItem
|
||||
key="move-right"
|
||||
onClick={() => {
|
||||
if (columnIndex < columnOrder.length - 1) {
|
||||
const newOrder = [...columnOrder]
|
||||
const [movedColumn] = newOrder.splice(columnIndex, 1)
|
||||
newOrder.splice(columnIndex + 1, 0, movedColumn)
|
||||
table.setColumnOrder(newOrder)
|
||||
}
|
||||
}}
|
||||
disabled={!canMoveRight || isPinned !== false}
|
||||
>
|
||||
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
|
||||
<span>Move to Right</span>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
hasPreviousSection = true
|
||||
}
|
||||
|
||||
// Visibility section
|
||||
if (props.tableLayout?.columnsVisibility && visibility) {
|
||||
if (hasPreviousSection) {
|
||||
items.push(<DropdownMenuSeparator key="sep-visibility" />)
|
||||
}
|
||||
items.push(
|
||||
<DropdownMenuSub key="visibility">
|
||||
<DropdownMenuSubTrigger>
|
||||
<Settings2Icon className="size-3.5!" />
|
||||
<span>Columns</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent side="right">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((col) => col.getCanHide())
|
||||
.map((col) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={col.id}
|
||||
checked={col.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => col.toggleVisibility(!!value)}
|
||||
className="capitalize"
|
||||
>
|
||||
{getColumnHeaderLabel(col)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
filter,
|
||||
canSort,
|
||||
isSorted,
|
||||
column,
|
||||
props.tableLayout?.columnsPinnable,
|
||||
props.tableLayout?.columnsMovable,
|
||||
props.tableLayout?.columnsVisibility,
|
||||
canPin,
|
||||
isPinned,
|
||||
canMoveLeft,
|
||||
canMoveRight,
|
||||
visibility,
|
||||
table,
|
||||
columnIndex,
|
||||
columnOrder,
|
||||
columnVisibilityKey, // Needed to update checkbox states when visibility changes
|
||||
])
|
||||
|
||||
if (hasControls) {
|
||||
return (
|
||||
<div className="-ms-2 flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent className="w-40" align="start">
|
||||
{menuItems}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className="rounded-lg -me-1 size-7"
|
||||
onClick={() => column.pin(false)}
|
||||
aria-label={`Unpin ${resolvedTitle} column`}
|
||||
title={`Unpin ${resolvedTitle} column`}
|
||||
>
|
||||
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
|
||||
return (
|
||||
<div className="-ms-2 flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading}
|
||||
onClick={handleSort}
|
||||
>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
{sortIcon}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={headerLabelClassName}>
|
||||
{icon && icon}
|
||||
{resolvedTitle}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DataGridColumnHeader = memo(
|
||||
DataGridColumnHeaderInner
|
||||
) as typeof DataGridColumnHeaderInner
|
||||
|
||||
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import { ReactElement } from "react"
|
||||
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
|
||||
import { Table } from "@tanstack/react-table"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@evofw/ui/components/dropdown-menu"
|
||||
|
||||
function DataGridColumnVisibility<TData>({
|
||||
table,
|
||||
trigger,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
trigger: ReactElement<Record<string, unknown>>
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={trigger} />
|
||||
<DropdownMenuContent align="end" className="min-w-[150px]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="font-medium">
|
||||
Toggle Columns
|
||||
</DropdownMenuLabel>
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{getColumnHeaderLabel(column)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridColumnVisibility }
|
||||
@@ -0,0 +1,227 @@
|
||||
"use no memo"
|
||||
|
||||
import React, { ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@evofw/ui/components/select"
|
||||
import { Skeleton } from "@evofw/ui/components/skeleton"
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
interface DataGridPaginationProps {
|
||||
sizes?: number[]
|
||||
sizesInfo?: string
|
||||
sizesLabel?: string
|
||||
sizesDescription?: string
|
||||
sizesSkeleton?: ReactNode
|
||||
more?: boolean
|
||||
moreLimit?: number
|
||||
info?: string
|
||||
infoSkeleton?: ReactNode
|
||||
className?: string
|
||||
rowsPerPageLabel?: string
|
||||
previousPageLabel?: string
|
||||
nextPageLabel?: string
|
||||
ellipsisText?: string
|
||||
}
|
||||
|
||||
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
|
||||
const { table, recordCount, isLoading } = useDataGrid()
|
||||
|
||||
const defaultProps: Partial<DataGridPaginationProps> = {
|
||||
sizes: [5, 10, 25, 50, 100],
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
info: "{from} - {to} of {count}",
|
||||
infoSkeleton: <Skeleton className="h-8 w-60" />,
|
||||
rowsPerPageLabel: "Rows per page",
|
||||
previousPageLabel: "Go to previous page",
|
||||
nextPageLabel: "Go to next page",
|
||||
ellipsisText: "...",
|
||||
}
|
||||
|
||||
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
|
||||
|
||||
const btnBaseClasses = "p-0 text-sm"
|
||||
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
|
||||
const pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const from = recordCount === 0 ? 0 : pageIndex * pageSize + 1
|
||||
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
|
||||
const pageCount = table.getPageCount()
|
||||
|
||||
// Replace placeholders in paginationInfo
|
||||
const paginationInfo = mergedProps.info
|
||||
? mergedProps.info
|
||||
.replaceAll("{from}", from.toString())
|
||||
.replaceAll("{to}", to.toString())
|
||||
.replaceAll("{count}", recordCount.toString())
|
||||
: `${from} - ${to} of ${recordCount}`
|
||||
|
||||
// Pagination limit logic
|
||||
const paginationMoreLimit = mergedProps.moreLimit || 5
|
||||
|
||||
// Determine the start and end of the pagination group
|
||||
const currentGroupStart =
|
||||
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
|
||||
const currentGroupEnd = Math.min(
|
||||
currentGroupStart + paginationMoreLimit,
|
||||
pageCount
|
||||
)
|
||||
|
||||
// Render page buttons based on the current group
|
||||
const renderPageButtons = () => {
|
||||
const buttons = []
|
||||
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
|
||||
buttons.push(
|
||||
<Button
|
||||
key={i}
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={cn(btnBaseClasses, "text-muted-foreground", {
|
||||
"bg-accent text-accent-foreground": pageIndex === i,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (pageIndex !== i) {
|
||||
table.setPageIndex(i)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return buttons
|
||||
}
|
||||
|
||||
// Render a "previous" ellipsis button if there are previous pages to show
|
||||
const renderEllipsisPrevButton = () => {
|
||||
if (currentGroupStart > 0) {
|
||||
return (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
onClick={() => table.setPageIndex(currentGroupStart - 1)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Render a "next" ellipsis button if there are more pages to show after the current group
|
||||
const renderEllipsisNextButton = () => {
|
||||
if (currentGroupEnd < pageCount) {
|
||||
return (
|
||||
<Button
|
||||
className={btnBaseClasses}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => table.setPageIndex(currentGroupEnd)}
|
||||
>
|
||||
{mergedProps.ellipsisText}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid-pagination"
|
||||
className={cn(
|
||||
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
|
||||
mergedProps.className
|
||||
)}
|
||||
>
|
||||
<div className="order-2 flex flex-wrap items-center space-x-2.5 pb-2.5 sm:order-1 sm:pb-0">
|
||||
{isLoading ? (
|
||||
mergedProps.sizesSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{mergedProps.rowsPerPageLabel}
|
||||
</div>
|
||||
<Select
|
||||
value={`${pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
const newPageSize = Number(value)
|
||||
table.setPageSize(newPageSize)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-16" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
align="start"
|
||||
alignItemWithTrigger={false}
|
||||
className="min-w-(--anchor-width)"
|
||||
>
|
||||
{mergedProps.sizes?.map((size: number) => (
|
||||
<SelectItem key={size} value={`${size}`}>
|
||||
{size}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
|
||||
{isLoading ? (
|
||||
mergedProps.infoSkeleton
|
||||
) : (
|
||||
<>
|
||||
<div className="text-muted-foreground order-2 text-sm text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{mergedProps.previousPageLabel}
|
||||
</span>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{renderEllipsisPrevButton()}
|
||||
|
||||
{renderPageButtons()}
|
||||
|
||||
{renderEllipsisNextButton()}
|
||||
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={btnArrowClasses}
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">{mergedProps.nextPageLabel}</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridPagination, type DataGridPaginationProps }
|
||||
@@ -0,0 +1,469 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
PointerEvent,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
const MIN_THUMB_SIZE = 24
|
||||
const FALLBACK_SCROLLBAR_SIZE = 12
|
||||
|
||||
const INITIAL_METRICS = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight: 0,
|
||||
horizontalScrollbarSize: 0,
|
||||
thumbHeight: 0,
|
||||
thumbTop: 0,
|
||||
trackHeight: 0,
|
||||
} as const
|
||||
|
||||
const SCROLLBAR_CLASSNAME =
|
||||
"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
|
||||
|
||||
const SCROLLBAR_THUMB_CLASSNAME = "bg-border rounded-full relative flex-1"
|
||||
|
||||
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
|
||||
|
||||
type ScrollbarMetrics = {
|
||||
hasVerticalOverflow: boolean
|
||||
headerHeight: number
|
||||
horizontalScrollbarSize: number
|
||||
thumbHeight: number
|
||||
thumbTop: number
|
||||
trackHeight: number
|
||||
}
|
||||
|
||||
type ObservedElements = {
|
||||
header: HTMLElement | null
|
||||
horizontalScrollbar: HTMLElement | null
|
||||
table: HTMLElement | null
|
||||
tableViewport: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridScrollAreaProps = Omit<
|
||||
ScrollAreaPrimitive.Root.Props,
|
||||
"children"
|
||||
> & {
|
||||
children: ReactNode
|
||||
orientation?: DataGridScrollAreaOrientation
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
|
||||
return (
|
||||
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
|
||||
next.headerHeight === prev.headerHeight &&
|
||||
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
|
||||
next.thumbHeight === prev.thumbHeight &&
|
||||
next.thumbTop === prev.thumbTop &&
|
||||
next.trackHeight === prev.trackHeight
|
||||
)
|
||||
}
|
||||
|
||||
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-header-height",
|
||||
`${metrics.headerHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-height",
|
||||
`${metrics.thumbHeight}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-thumb-top",
|
||||
`${metrics.thumbTop}px`
|
||||
)
|
||||
element.style.setProperty(
|
||||
"--data-grid-scrollbar-track-height",
|
||||
`${metrics.trackHeight}px`
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridScrollArea({
|
||||
children,
|
||||
className,
|
||||
orientation = "both",
|
||||
...props
|
||||
}: DataGridScrollAreaProps) {
|
||||
const { props: dataGridProps, table } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null)
|
||||
const dragRef = useRef<{
|
||||
pointerId: number
|
||||
startScrollTop: number
|
||||
startY: number
|
||||
} | null>(null)
|
||||
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
|
||||
const observedElementsRef = useRef<ObservedElements>({
|
||||
header: null,
|
||||
horizontalScrollbar: null,
|
||||
table: null,
|
||||
tableViewport: null,
|
||||
})
|
||||
|
||||
const showHorizontal = orientation !== "vertical"
|
||||
const showVertical = orientation !== "horizontal"
|
||||
const usesCustomVerticalScrollbar =
|
||||
showVertical && !!dataGridProps.tableLayout?.headerSticky
|
||||
// Pinned columns are sticky and never scroll, so the horizontal scrollbar
|
||||
// track is inset to span only the scrollable center region between them.
|
||||
const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable
|
||||
const scrollbarInsetStart = isColumnsPinnable ? table.getLeftTotalSize() : 0
|
||||
const scrollbarInsetEnd = isColumnsPinnable ? table.getRightTotalSize() : 0
|
||||
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
|
||||
useState(false)
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
dragRef.current = null
|
||||
document.body.style.userSelect = ""
|
||||
document.body.style.webkitUserSelect = ""
|
||||
}, [])
|
||||
|
||||
const resetMetrics = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
|
||||
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
|
||||
applyMetrics(container, INITIAL_METRICS)
|
||||
metricsRef.current = INITIAL_METRICS
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
|
||||
}, [])
|
||||
|
||||
const syncCustomVerticalScrollbar = useCallback(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport || !usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
const { header, horizontalScrollbar } = observedElementsRef.current
|
||||
const headerHeight = header?.getBoundingClientRect().height ?? 0
|
||||
const viewportHeight = viewport.clientHeight
|
||||
const viewportWidth = viewport.clientWidth
|
||||
const scrollHeight = viewport.scrollHeight
|
||||
const scrollWidth = viewport.scrollWidth
|
||||
const hasHorizontalOverflow =
|
||||
showHorizontal && scrollWidth > viewportWidth + 0.5
|
||||
const horizontalScrollbarSize = hasHorizontalOverflow
|
||||
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
|
||||
: 0
|
||||
const trackHeight = Math.max(
|
||||
0,
|
||||
viewportHeight - headerHeight - horizontalScrollbarSize
|
||||
)
|
||||
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
|
||||
|
||||
let nextMetrics: ScrollbarMetrics
|
||||
|
||||
if (trackHeight === 0 || maxScroll === 0) {
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: false,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight: trackHeight,
|
||||
thumbTop: 0,
|
||||
trackHeight,
|
||||
}
|
||||
} else {
|
||||
const bodyContentHeight = Math.max(
|
||||
trackHeight,
|
||||
scrollHeight - headerHeight
|
||||
)
|
||||
const thumbHeight = clamp(
|
||||
trackHeight * (trackHeight / bodyContentHeight),
|
||||
MIN_THUMB_SIZE,
|
||||
trackHeight
|
||||
)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const thumbTop =
|
||||
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
|
||||
|
||||
nextMetrics = {
|
||||
hasVerticalOverflow: true,
|
||||
headerHeight,
|
||||
horizontalScrollbarSize,
|
||||
thumbHeight,
|
||||
thumbTop,
|
||||
trackHeight,
|
||||
}
|
||||
}
|
||||
|
||||
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
|
||||
applyMetrics(container, nextMetrics)
|
||||
metricsRef.current = nextMetrics
|
||||
}
|
||||
|
||||
setHasCustomVerticalOverflow((prev) =>
|
||||
prev === nextMetrics.hasVerticalOverflow
|
||||
? prev
|
||||
: nextMetrics.hasVerticalOverflow
|
||||
)
|
||||
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!container || !viewport) return
|
||||
|
||||
if (!usesCustomVerticalScrollbar) {
|
||||
resetMetrics()
|
||||
return
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
const observed = new Set<HTMLElement>()
|
||||
|
||||
const observeElement = (element: HTMLElement | null) => {
|
||||
if (element && observer && !observed.has(element)) {
|
||||
observer.observe(element)
|
||||
observed.add(element)
|
||||
}
|
||||
}
|
||||
|
||||
const resolveObservedElements = () => {
|
||||
observedElementsRef.current = {
|
||||
header: container.querySelector(
|
||||
'[data-slot="data-grid-table"] thead'
|
||||
) as HTMLElement | null,
|
||||
horizontalScrollbar: container.querySelector(
|
||||
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
|
||||
) as HTMLElement | null,
|
||||
table: container.querySelector(
|
||||
'[data-slot="data-grid-table"]'
|
||||
) as HTMLElement | null,
|
||||
tableViewport: container.querySelector(
|
||||
'[data-slot="data-grid-table-viewport"]'
|
||||
) as HTMLElement | null,
|
||||
}
|
||||
|
||||
observeElement(observedElementsRef.current.header)
|
||||
observeElement(observedElementsRef.current.table)
|
||||
observeElement(observedElementsRef.current.tableViewport)
|
||||
|
||||
return !!(
|
||||
observedElementsRef.current.header && observedElementsRef.current.table
|
||||
)
|
||||
}
|
||||
|
||||
observeElement(viewport)
|
||||
const resolvedOnMount = resolveObservedElements()
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
// A table that mounts after this effect (empty state swapped for data)
|
||||
// would otherwise never be observed and the custom scrollbar would
|
||||
// overlap the sticky header. One-shot: disconnects once resolved.
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
if (!resolvedOnMount && typeof MutationObserver !== "undefined") {
|
||||
mutationObserver = new MutationObserver(() => {
|
||||
if (resolveObservedElements()) {
|
||||
mutationObserver?.disconnect()
|
||||
mutationObserver = null
|
||||
scheduleSync()
|
||||
}
|
||||
})
|
||||
mutationObserver.observe(container, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.disconnect()
|
||||
mutationObserver?.disconnect()
|
||||
viewport.removeEventListener("scroll", scheduleSync)
|
||||
clearDragState()
|
||||
}
|
||||
}, [
|
||||
clearDragState,
|
||||
resetMetrics,
|
||||
syncCustomVerticalScrollbar,
|
||||
usesCustomVerticalScrollbar,
|
||||
])
|
||||
|
||||
const scrollToThumbOffset = (nextThumbTop: number) => {
|
||||
const viewport = viewportRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
|
||||
if (maxScroll === 0 || maxThumbTop === 0) {
|
||||
viewport.scrollTop = 0
|
||||
return
|
||||
}
|
||||
|
||||
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
|
||||
viewport.scrollTop = ratio * maxScroll
|
||||
}
|
||||
|
||||
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
|
||||
if (!viewport) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startScrollTop: viewport.scrollTop,
|
||||
startY: event.clientY,
|
||||
}
|
||||
|
||||
document.body.style.userSelect = "none"
|
||||
document.body.style.webkitUserSelect = "none"
|
||||
}
|
||||
|
||||
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current
|
||||
const dragState = dragRef.current
|
||||
const { thumbHeight, trackHeight } = metricsRef.current
|
||||
|
||||
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
|
||||
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
|
||||
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
|
||||
|
||||
if (maxThumbTop === 0 || maxScroll === 0) return
|
||||
|
||||
const deltaY = event.clientY - dragState.startY
|
||||
const nextScrollTop =
|
||||
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
|
||||
|
||||
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
|
||||
}
|
||||
|
||||
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
|
||||
if (dragRef.current?.pointerId !== event.pointerId) return
|
||||
clearDragState()
|
||||
}
|
||||
|
||||
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const { thumbHeight } = metricsRef.current
|
||||
|
||||
if (event.target !== event.currentTarget) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const offsetY = event.clientY - rect.top - thumbHeight / 2
|
||||
|
||||
scrollToThumbOffset(offsetY)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="data-grid-scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
ref={viewportRef}
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full"
|
||||
>
|
||||
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Content>
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
|
||||
{showHorizontal && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="horizontal"
|
||||
orientation="horizontal"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
style={
|
||||
scrollbarInsetStart > 0 || scrollbarInsetEnd > 0
|
||||
? {
|
||||
marginInlineStart: scrollbarInsetStart || undefined,
|
||||
marginInlineEnd: scrollbarInsetEnd || undefined,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
{showVertical && !usesCustomVerticalScrollbar && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
className={SCROLLBAR_CLASSNAME}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className={SCROLLBAR_THUMB_CLASSNAME}
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
</ScrollAreaPrimitive.Root>
|
||||
|
||||
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
|
||||
>
|
||||
<div
|
||||
className="pointer-events-auto relative h-full w-2 touch-none p-px"
|
||||
onPointerDown={handleTrackPointerDown}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-border absolute end-px w-2",
|
||||
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
|
||||
"rounded-full"
|
||||
)}
|
||||
onLostPointerCapture={clearDragState}
|
||||
onPointerCancel={handleThumbPointerUp}
|
||||
onPointerDown={handleThumbPointerDown}
|
||||
onPointerMove={handleThumbPointerMove}
|
||||
onPointerUp={handleThumbPointerUp}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridScrollArea }
|
||||
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
|
||||
@@ -0,0 +1,346 @@
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
memo,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type Modifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
Cell,
|
||||
flexRender,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import { GripHorizontalIcon } from "lucide-react"
|
||||
|
||||
// Context to share sortable listeners from row to handle
|
||||
type SortableContextValue = ReturnType<typeof useSortable>
|
||||
const SortableRowContext = createContext<Pick<
|
||||
SortableContextValue,
|
||||
"attributes" | "listeners"
|
||||
> | null>(null)
|
||||
|
||||
function DataGridTableDndRowHandle({ className }: { className?: string }) {
|
||||
const context = useContext(SortableRowContext)
|
||||
|
||||
if (!context) {
|
||||
// Fallback if context is not available (shouldn't happen in normal usage)
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(
|
||||
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
|
||||
className
|
||||
)}
|
||||
aria-label="Drag to reorder row"
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
|
||||
const {
|
||||
transform,
|
||||
transition,
|
||||
setNodeRef,
|
||||
isDragging,
|
||||
attributes,
|
||||
listeners,
|
||||
} = useSortable({
|
||||
id: row.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition: transition,
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
position: "relative",
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableRowContext.Provider value={{ attributes, listeners }}>
|
||||
<DataGridTableBodyRow row={row} dndRef={setNodeRef} dndStyle={style}>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRowsBody<TData>({
|
||||
table,
|
||||
dataIds,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
dataIds: UniqueIdentifier[]
|
||||
}) {
|
||||
const { isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
|
||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||
|
||||
return (
|
||||
<SortableContext items={dataIds} strategy={verticalListSortingStrategy}>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized body rows: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedDataGridTableDndRowsBody = memo(
|
||||
DataGridTableDndRowsBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
) as typeof DataGridTableDndRowsBody
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, props } = useDataGrid()
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
// Keyboard reordering moves one sortable position per keypress instead
|
||||
// of the sensor's raw 25px default.
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingRow) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingRow])
|
||||
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableContainer: Modifier = ({
|
||||
transform,
|
||||
draggingNodeRect,
|
||||
}) => {
|
||||
if (!tableContainerRef.current || !draggingNodeRect) {
|
||||
return transform
|
||||
}
|
||||
|
||||
const containerRect = tableContainerRef.current.getBoundingClientRect()
|
||||
const { x, y } = transform
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left
|
||||
const maxX = containerRect.right - draggingNodeRect.right
|
||||
const minY = containerRect.top - draggingNodeRect.top
|
||||
const maxY = containerRect.bottom - draggingNodeRect.bottom
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.max(minX, Math.min(maxX, x)),
|
||||
y: Math.max(minY, Math.min(maxY, y)),
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToVerticalAxis, restrictToTableContainer]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
id={useId()}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingRow(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingRow(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingRow(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={tableContainerRef}
|
||||
className={
|
||||
isDraggingRow
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
{headerGroup.headers.map((header, index) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={index}>
|
||||
{header.isPlaceholder ? null : props.tableLayout
|
||||
?.columnsResizable && column.getCanResize() ? (
|
||||
<div className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillHeadCell />
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedDataGridTableDndRowsBody table={table} dataIds={dataIds} />
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client"
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
memo,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
Modifier,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
Cell,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@evofw/ui/components/button"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
|
||||
function DataGridTableDndHeader<TData>({
|
||||
header,
|
||||
}: {
|
||||
header: Header<TData, unknown>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const { column } = header
|
||||
|
||||
// Check if column ordering is enabled for this column
|
||||
const canOrder =
|
||||
(column.columnDef as { enableColumnOrdering?: boolean })
|
||||
.enableColumnOrdering !== false
|
||||
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
id: header.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
whiteSpace: "nowrap",
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--header-${header.id}-size) * 1px)`
|
||||
: header.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell
|
||||
header={header}
|
||||
dndStyle={style}
|
||||
dndRef={setNodeRef}
|
||||
>
|
||||
<div className="flex items-center justify-start gap-0.5">
|
||||
{canOrder && (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="grow">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{props.tableLayout?.columnsResizable && column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</div>
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
|
||||
const { props } = useDataGrid()
|
||||
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||
id: cell.column.id,
|
||||
})
|
||||
|
||||
const style: CSSProperties = {
|
||||
opacity: isDragging ? 0.8 : 1,
|
||||
position: "relative",
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
cursor: isDragging ? "grabbing" : undefined,
|
||||
width: props.tableLayout?.columnsResizable
|
||||
? `calc(var(--col-${cell.column.id}-size) * 1px)`
|
||||
: cell.column.getSize(),
|
||||
zIndex: isDragging ? 1 : 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndBodyRows<TData>({ table }: { table: Table<TData> }) {
|
||||
const { isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
|
||||
if (props.loadingMode === "skeleton" && isLoading && pagination?.pageSize) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
|
||||
<DataGridTableBodyRowSkeleton key={rowIndex}>
|
||||
{table.getVisibleFlatColumns().map((column, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowSkeletonCell
|
||||
column={column}
|
||||
key={colIndex}
|
||||
>
|
||||
{column.columnDef.meta?.skeleton}
|
||||
</DataGridTableBodyRowSkeletonCell>
|
||||
)
|
||||
})}
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!table.getRowModel().rows.length) return <DataGridTableEmpty />
|
||||
|
||||
return (
|
||||
<>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>) => (
|
||||
<DataGridTableDndCell cell={cell} key={cell.id} />
|
||||
))}
|
||||
</SortableContext>
|
||||
<DataGridTableFillBodyCell />
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && <DataGridTableBodyRowExpandded row={row} />}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized body rows: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedDataGridTableDndBodyRows = memo(
|
||||
DataGridTableDndBodyRows,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
) as typeof DataGridTableDndBodyRows
|
||||
|
||||
function DataGridTableDnd<TData>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, props } = useDataGrid()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
// Keyboard reordering moves one sortable position per keypress instead
|
||||
// of the sensor's raw 25px default.
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDraggingColumn) return
|
||||
|
||||
const { body, documentElement } = document
|
||||
const previousBodyCursor = body.style.cursor
|
||||
const previousDocumentCursor = documentElement.style.cursor
|
||||
|
||||
body.style.cursor = "grabbing"
|
||||
documentElement.style.cursor = "grabbing"
|
||||
|
||||
return () => {
|
||||
body.style.cursor = previousBodyCursor
|
||||
documentElement.style.cursor = previousDocumentCursor
|
||||
}
|
||||
}, [isDraggingColumn])
|
||||
|
||||
// Custom modifier to restrict dragging within table bounds with edge offset
|
||||
const modifiers = useMemo(() => {
|
||||
const restrictToTableBounds: Modifier = ({
|
||||
draggingNodeRect,
|
||||
transform,
|
||||
}) => {
|
||||
if (!draggingNodeRect || !containerRef.current) {
|
||||
return { ...transform, y: 0 }
|
||||
}
|
||||
|
||||
const containerRect = containerRef.current.getBoundingClientRect()
|
||||
const edgeOffset = 0
|
||||
|
||||
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
|
||||
const maxX =
|
||||
containerRect.right -
|
||||
draggingNodeRect.left -
|
||||
draggingNodeRect.width +
|
||||
edgeOffset
|
||||
|
||||
return {
|
||||
...transform,
|
||||
x: Math.min(Math.max(transform.x, minX), maxX),
|
||||
y: 0, // Lock vertical movement
|
||||
}
|
||||
}
|
||||
|
||||
return [restrictToTableBounds]
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={modifiers}
|
||||
onDragCancel={() => setIsDraggingColumn(false)}
|
||||
onDragEnd={(event) => {
|
||||
setIsDraggingColumn(false)
|
||||
handleDragEnd(event)
|
||||
}}
|
||||
onDragStart={() => setIsDraggingColumn(true)}
|
||||
sensors={sensors}
|
||||
>
|
||||
<DataGridTableViewport
|
||||
viewportRef={containerRef}
|
||||
className={
|
||||
isDraggingColumn
|
||||
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
|
||||
: "relative"
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => {
|
||||
return (
|
||||
<DataGridTableHeadRow key={index} rowId={headerGroup.id}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DataGridTableFillHeadCell />
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedDataGridTableDndBodyRows table={table} />
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
@@ -0,0 +1,633 @@
|
||||
"use no memo"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFillBodyCell,
|
||||
DataGridTableFillHeadCell,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridScrollAreaViewport,
|
||||
getDataGridTableMergedHeaderGroups,
|
||||
getDataGridTableRowSections,
|
||||
getPinningStyles,
|
||||
hasDataGridTableRightPinnedColumns,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { Column, flexRender, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
VirtualizerOptions,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
import { Spinner } from "@evofw/ui/components/spinner"
|
||||
|
||||
type DataGridTableVirtualScrollElements = {
|
||||
containerElement: HTMLDivElement | null
|
||||
scrollElement: HTMLElement | null
|
||||
}
|
||||
|
||||
type DataGridTableVirtualizerInstance = Virtualizer<
|
||||
HTMLElement,
|
||||
HTMLTableRowElement
|
||||
>
|
||||
|
||||
type DataGridTableVirtualizerOptions<TData> = Omit<
|
||||
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
|
||||
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
|
||||
> & {
|
||||
estimateSize?: (index: number, row: Row<TData>) => number
|
||||
getItemKey?: (index: number, row: Row<TData>) => string | number
|
||||
getScrollElement?: (
|
||||
elements: DataGridTableVirtualScrollElements
|
||||
) => HTMLElement | null
|
||||
}
|
||||
|
||||
interface DataGridTableVirtualProps<TData> {
|
||||
height?: number | string
|
||||
estimateSize?: number
|
||||
overscan?: number
|
||||
footerContent?: ReactNode
|
||||
renderHeader?: boolean
|
||||
onFetchMore?: () => void
|
||||
isFetchingMore?: boolean
|
||||
hasMore?: boolean
|
||||
fetchMoreOffset?: number
|
||||
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
|
||||
}
|
||||
|
||||
interface VirtualBodyProps<TData> {
|
||||
table: Table<TData>
|
||||
topRows: Row<TData>[]
|
||||
centerRows: Row<TData>[]
|
||||
bottomRows: Row<TData>[]
|
||||
virtualItems: VirtualItem[]
|
||||
totalSize: number
|
||||
isVirtualizationEnabled: boolean
|
||||
isInfiniteMode: boolean
|
||||
isFetchingMore: boolean
|
||||
hasMore?: boolean
|
||||
loadingMoreMessage: ReactNode
|
||||
allRowsLoadedMessage: ReactNode
|
||||
measureRowRef?: (element: HTMLTableRowElement | null) => void
|
||||
}
|
||||
|
||||
function DataGridTableVirtualPinnedPlaceholderCell<TData>({
|
||||
column,
|
||||
}: {
|
||||
column: Column<TData>
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const isPinned = column.getIsPinned()
|
||||
const isLastLeftPinned = isPinned === "left" && column.getIsLastColumn("left")
|
||||
const isFirstRightPinned =
|
||||
isPinned === "right" && column.getIsFirstColumn("right")
|
||||
|
||||
return (
|
||||
<td
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
...(props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
getPinningStyles(column)),
|
||||
...(props.tableLayout?.columnsResizable && {
|
||||
width: `calc(var(--col-${column.id}-size) * 1px)`,
|
||||
}),
|
||||
}}
|
||||
data-pinned={isPinned || undefined}
|
||||
data-last-col={
|
||||
isLastLeftPinned ? "left" : isFirstRightPinned ? "right" : undefined
|
||||
}
|
||||
className={cn(
|
||||
"p-0",
|
||||
props.tableLayout?.cellBorder && "border-e",
|
||||
props.tableLayout?.columnsPinnable &&
|
||||
column.getCanPin() &&
|
||||
"data-pinned:bg-background data-pinned:isolate [&[data-pinned=left][data-last-col=left]]:shadow-[inset_-1px_0_0_0_var(--border)] [&[data-pinned=right][data-last-col=right]]:shadow-[inset_1px_0_0_0_var(--border)]"
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualUtilityRow<TData>({
|
||||
table,
|
||||
children,
|
||||
centerCellClassName,
|
||||
centerCellStyle,
|
||||
rowClassName,
|
||||
ariaHidden,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
children: ReactNode
|
||||
centerCellClassName?: string
|
||||
centerCellStyle?: CSSProperties
|
||||
rowClassName?: string
|
||||
ariaHidden?: boolean
|
||||
}) {
|
||||
const { props } = useDataGrid()
|
||||
const leftVisibleColumns = table.getLeftVisibleLeafColumns()
|
||||
const centerVisibleColumns = table.getCenterVisibleLeafColumns()
|
||||
const rightVisibleColumns = table.getRightVisibleLeafColumns()
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
|
||||
return (
|
||||
<tr aria-hidden={ariaHidden || undefined} className={rowClassName}>
|
||||
{leftVisibleColumns.map((column) => (
|
||||
<DataGridTableVirtualPinnedPlaceholderCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
/>
|
||||
))}
|
||||
<td
|
||||
colSpan={Math.max(centerVisibleColumns.length, 1)}
|
||||
className={centerCellClassName}
|
||||
style={centerCellStyle}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
{props.tableLayout?.columnsResizable && hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
{rightVisibleColumns.map((column) => (
|
||||
<DataGridTableVirtualPinnedPlaceholderCell
|
||||
column={column}
|
||||
key={column.id}
|
||||
/>
|
||||
))}
|
||||
{props.tableLayout?.columnsResizable && !hasRightPinnedColumns ? (
|
||||
<DataGridTableFillBodyCell />
|
||||
) : null}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualSpacer<TData>({
|
||||
table,
|
||||
height,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
ariaHidden
|
||||
centerCellClassName="p-0"
|
||||
centerCellStyle={{ height, padding: 0 }}
|
||||
>
|
||||
{null}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow<TData>({
|
||||
table,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
table: Table<TData>
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<DataGridTableVirtualUtilityRow
|
||||
table={table}
|
||||
centerCellClassName={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DataGridTableVirtualUtilityRow>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
virtualItems,
|
||||
totalSize,
|
||||
isVirtualizationEnabled,
|
||||
isInfiniteMode,
|
||||
isFetchingMore,
|
||||
hasMore,
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const { isLoading } = useDataGrid()
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
|
||||
if (!totalRows) {
|
||||
// Initial load must not flash the empty state as if the query returned
|
||||
// nothing.
|
||||
if (isLoading) {
|
||||
return (
|
||||
<DataGridTableVirtualStatusRow table={table}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
</div>
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
return <DataGridTableEmpty />
|
||||
}
|
||||
|
||||
const hasCenterRows = centerRows.length > 0
|
||||
const showFetchingRow = isInfiniteMode && isFetchingMore
|
||||
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
|
||||
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
|
||||
const leadingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? (virtualItems[0]?.start ?? 0)
|
||||
: 0
|
||||
const trailingSpacerHeight =
|
||||
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
|
||||
? Math.max(
|
||||
0,
|
||||
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
|
||||
)
|
||||
: 0
|
||||
|
||||
const renderedRows: ReactNode[] = []
|
||||
|
||||
topRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (isVirtualizationEnabled) {
|
||||
if (leadingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-start"
|
||||
table={table}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
virtualItems.forEach((virtualRow) => {
|
||||
const row = centerRows[virtualRow.index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
rowIndex={virtualRow.index}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (trailingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
table={table}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow key="virtual-status-loading" table={table}>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="size-4 opacity-60" />
|
||||
{loadingMoreMessage}
|
||||
</div>
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
if (showCompleteRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-complete"
|
||||
table={table}
|
||||
className="py-3 text-xs"
|
||||
>
|
||||
{allRowsLoadedMessage}
|
||||
</DataGridTableVirtualStatusRow>
|
||||
)
|
||||
}
|
||||
|
||||
bottomRows.forEach((row, index) => {
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
pinnedBoundary={
|
||||
index === 0 && (topRows.length > 0 || hasMiddleSection)
|
||||
? "bottom"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
return <>{renderedRows}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized virtual body: skip re-renders during active column resize.
|
||||
* Column widths update via CSS variables on the <table> element,
|
||||
* so the browser handles width changes without React re-renders.
|
||||
*/
|
||||
const MemoizedVirtualBody = memo(
|
||||
DataGridTableVirtualBody,
|
||||
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
|
||||
) as typeof DataGridTableVirtualBody
|
||||
|
||||
function DataGridTableVirtual<TData>({
|
||||
height,
|
||||
estimateSize = 48,
|
||||
overscan = 10,
|
||||
footerContent,
|
||||
renderHeader = true,
|
||||
onFetchMore,
|
||||
isFetchingMore = false,
|
||||
hasMore,
|
||||
fetchMoreOffset = 0,
|
||||
virtualizerOptions,
|
||||
}: DataGridTableVirtualProps<TData>) {
|
||||
const { table, props } = useDataGrid()
|
||||
const mergedHeaderGroups = getDataGridTableMergedHeaderGroups(table)
|
||||
const hasRightPinnedColumns = hasDataGridTableRightPinnedColumns(table)
|
||||
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const isInfiniteMode = typeof onFetchMore === "function"
|
||||
const [viewportElements, setViewportElements] =
|
||||
useState<DataGridTableVirtualScrollElements>({
|
||||
containerElement: null,
|
||||
scrollElement: null,
|
||||
})
|
||||
|
||||
const {
|
||||
estimateSize: customEstimateSize,
|
||||
getItemKey: customGetItemKey,
|
||||
getScrollElement: customGetScrollElement,
|
||||
measureElement: customMeasureElement,
|
||||
overscan: customOverscan,
|
||||
...virtualizerOptionsRest
|
||||
} = virtualizerOptions ?? {}
|
||||
|
||||
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
|
||||
const loadingMoreMessage =
|
||||
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
|
||||
const allRowsLoadedMessage =
|
||||
props.allRowsLoadedMessage || "All records loaded"
|
||||
|
||||
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
|
||||
setViewportElements({
|
||||
containerElement: node,
|
||||
scrollElement: node
|
||||
? (getDataGridScrollAreaViewport(node) ?? node)
|
||||
: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
const usesExternalScrollArea =
|
||||
viewportElements.scrollElement !== null &&
|
||||
viewportElements.scrollElement !== viewportElements.containerElement
|
||||
|
||||
const resolveScrollElement = useCallback(() => {
|
||||
if (customGetScrollElement) {
|
||||
return customGetScrollElement(viewportElements)
|
||||
}
|
||||
|
||||
return viewportElements.scrollElement
|
||||
}, [customGetScrollElement, viewportElements])
|
||||
|
||||
const resolveItemKey = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
if (!row) return index
|
||||
|
||||
return customGetItemKey?.(index, row) ?? row.id ?? index
|
||||
},
|
||||
[centerRows, customGetItemKey]
|
||||
)
|
||||
|
||||
const resolveEstimateSize = useCallback(
|
||||
(index: number) => {
|
||||
const row = centerRows[index]
|
||||
|
||||
return row
|
||||
? (customEstimateSize?.(index, row) ?? estimateSize)
|
||||
: estimateSize
|
||||
},
|
||||
[centerRows, customEstimateSize, estimateSize]
|
||||
)
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: centerRows.length,
|
||||
getScrollElement: resolveScrollElement,
|
||||
getItemKey: resolveItemKey,
|
||||
estimateSize: resolveEstimateSize,
|
||||
overscan: customOverscan ?? overscan,
|
||||
measureElement: customMeasureElement,
|
||||
...virtualizerOptionsRest,
|
||||
}) as DataGridTableVirtualizerInstance
|
||||
|
||||
const virtualItems = isVirtualizationEnabled
|
||||
? virtualizer.getVirtualItems()
|
||||
: []
|
||||
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
|
||||
const measureRowRef =
|
||||
isVirtualizationEnabled && customMeasureElement
|
||||
? virtualizer.measureElement
|
||||
: undefined
|
||||
const resolvedFetchMoreOffset = Math.max(0, fetchMoreOffset)
|
||||
// Latch onFetchMore per row count: virtualItems gets a new identity every
|
||||
// scroll frame, so without it the effect fires duplicate page requests
|
||||
// before the consumer flips isFetchingMore, and loops at end-of-data when
|
||||
// hasMore is never set.
|
||||
const fetchMoreFiredAtCountRef = useRef<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isVirtualizationEnabled ||
|
||||
!isInfiniteMode ||
|
||||
hasMore === false ||
|
||||
isFetchingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
|
||||
if (fetchMoreFiredAtCountRef.current === centerRows.length) return
|
||||
|
||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||
fetchMoreFiredAtCountRef.current = centerRows.length
|
||||
onFetchMore?.()
|
||||
}
|
||||
}, [
|
||||
centerRows.length,
|
||||
hasMore,
|
||||
isFetchingMore,
|
||||
isInfiniteMode,
|
||||
isVirtualizationEnabled,
|
||||
onFetchMore,
|
||||
resolvedFetchMoreOffset,
|
||||
virtualItems,
|
||||
])
|
||||
|
||||
return (
|
||||
<DataGridTableViewport
|
||||
viewportRef={handleViewportRef}
|
||||
className={!usesExternalScrollArea ? "block" : undefined}
|
||||
style={
|
||||
usesExternalScrollArea
|
||||
? undefined
|
||||
: {
|
||||
height,
|
||||
overflow: "auto",
|
||||
position: "relative",
|
||||
// Standalone mode: this node IS the scroll container, so it
|
||||
// must stay at its parent's width (not the resizable table
|
||||
// width) or horizontal scrolling becomes impossible.
|
||||
width: "auto",
|
||||
}
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{mergedHeaderGroups.map((headerGroup) => (
|
||||
<DataGridTableHeadRow key={headerGroup.id} rowId={headerGroup.id}>
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() !== "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
{headerGroup.headers
|
||||
.filter((header) => header.column.getIsPinned() === "right")
|
||||
.map((header) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
column.getCanResize() && (
|
||||
<DataGridTableHeadRowCellResize header={header} />
|
||||
)}
|
||||
</DataGridTableHeadRowCell>
|
||||
)
|
||||
})}
|
||||
{props.tableLayout?.columnsResizable &&
|
||||
!hasRightPinnedColumns ? (
|
||||
<DataGridTableFillHeadCell />
|
||||
) : null}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
topRows={topRows}
|
||||
centerRows={centerRows}
|
||||
bottomRows={bottomRows}
|
||||
virtualItems={virtualItems}
|
||||
totalSize={totalSize}
|
||||
isVirtualizationEnabled={isVirtualizationEnabled}
|
||||
isInfiniteMode={isInfiniteMode}
|
||||
isFetchingMore={isFetchingMore}
|
||||
hasMore={hasMore}
|
||||
loadingMoreMessage={loadingMoreMessage}
|
||||
allRowsLoadedMessage={allRowsLoadedMessage}
|
||||
measureRowRef={measureRowRef}
|
||||
/>
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableVirtual }
|
||||
export type {
|
||||
DataGridTableVirtualProps,
|
||||
DataGridTableVirtualScrollElements,
|
||||
DataGridTableVirtualizerOptions,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
"use no memo"
|
||||
|
||||
import { createContext, ReactNode, useContext, useMemo, useRef } from "react"
|
||||
import {
|
||||
Column,
|
||||
ColumnFiltersState,
|
||||
RowData,
|
||||
SortingState,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
headerTitle?: string
|
||||
headerClassName?: string
|
||||
cellClassName?: string
|
||||
skeleton?: ReactNode
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
autoSize?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
|
||||
export function getColumnHeaderLabel<TData, TValue>(
|
||||
column: Column<TData, TValue>
|
||||
): string {
|
||||
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
|
||||
if (typeof meta?.headerTitle === "string") return meta.headerTitle
|
||||
const defHeader = column.columnDef.header
|
||||
if (typeof defHeader === "string") return defHeader
|
||||
return String(column.id)
|
||||
}
|
||||
|
||||
export type DataGridApiFetchParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
filters?: ColumnFiltersState
|
||||
searchQuery?: string
|
||||
}
|
||||
|
||||
export type DataGridApiResponse<T> = {
|
||||
data: T[]
|
||||
empty: boolean
|
||||
pagination: {
|
||||
total: number
|
||||
page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DataGridContextProps<TData extends object> {
|
||||
props: DataGridProps<TData>
|
||||
table: Table<TData>
|
||||
recordCount: number
|
||||
isLoading: boolean
|
||||
/**
|
||||
* Internal coordinator for `meta.autoSize` columns. Lives at the core level
|
||||
* so every table variant and viewport instance shares one application state.
|
||||
*/
|
||||
autoSize?: DataGridAutoSizeController
|
||||
}
|
||||
|
||||
export type DataGridAutoSizeController = {
|
||||
/**
|
||||
* Grows the first visible `meta.autoSize` column by the given free space.
|
||||
* Applies at most once per column id; safe to call from every viewport
|
||||
* measurement. Returns true when a sizing update was dispatched.
|
||||
*/
|
||||
apply: (fillWidth: number) => boolean
|
||||
}
|
||||
|
||||
function createDataGridAutoSizeController<TData extends object>(
|
||||
table: Table<TData>
|
||||
): DataGridAutoSizeController {
|
||||
let applied: { columnId: string; base: number; grown: number } | null = null
|
||||
|
||||
return {
|
||||
apply(fillWidth: number) {
|
||||
const columnSizing = table.getState().columnSizing
|
||||
|
||||
// Re-arm after reset flows (double-click resetSize, resetColumnSizing,
|
||||
// controlled state replacement) so the column re-fills instead of
|
||||
// leaving a dead blank strip.
|
||||
if (applied && columnSizing[applied.columnId] === undefined) {
|
||||
applied = null
|
||||
}
|
||||
|
||||
if (fillWidth <= 0) return false
|
||||
|
||||
const autoSizeColumn = table
|
||||
.getVisibleLeafColumns()
|
||||
.find(
|
||||
(column) => column.columnDef.meta?.autoSize && column.getCanResize()
|
||||
)
|
||||
|
||||
if (!autoSizeColumn || applied?.columnId === autoSizeColumn.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Candidate switched (e.g. the grown column was hidden and another
|
||||
// meta.autoSize column took over): revert the previous growth if the
|
||||
// user hasn't manually resized that column since, so visibility
|
||||
// toggles cannot ratchet the table wider than its container forever.
|
||||
const revert =
|
||||
applied && columnSizing[applied.columnId] === applied.grown
|
||||
? applied
|
||||
: null
|
||||
const base = columnSizing[autoSizeColumn.id] ?? autoSizeColumn.getSize()
|
||||
const grown = base + fillWidth
|
||||
|
||||
applied = { columnId: autoSizeColumn.id, base, grown }
|
||||
table.setColumnSizing((old) => {
|
||||
const next = { ...old, [autoSizeColumn.id]: grown }
|
||||
if (revert && next[revert.columnId] === revert.grown) {
|
||||
next[revert.columnId] = revert.base
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type DataGridRequestParams = {
|
||||
pageIndex: number
|
||||
pageSize: number
|
||||
sorting?: SortingState
|
||||
columnFilters?: ColumnFiltersState
|
||||
}
|
||||
|
||||
export interface DataGridProps<TData extends object> {
|
||||
className?: string
|
||||
table?: Table<TData>
|
||||
recordCount: number
|
||||
children?: ReactNode
|
||||
onRowClick?: (row: TData) => void
|
||||
isLoading?: boolean
|
||||
loadingMode?: "skeleton" | "spinner"
|
||||
loadingMessage?: ReactNode | string
|
||||
fetchingMoreMessage?: ReactNode | string
|
||||
allRowsLoadedMessage?: ReactNode | string
|
||||
emptyMessage?: ReactNode | string
|
||||
tableLayout?: {
|
||||
dense?: boolean
|
||||
cellBorder?: boolean
|
||||
rowBorder?: boolean
|
||||
rowRounded?: boolean
|
||||
stripped?: boolean
|
||||
headerBackground?: boolean
|
||||
footerBackground?: boolean
|
||||
headerBorder?: boolean
|
||||
headerSticky?: boolean
|
||||
width?: "auto" | "fixed"
|
||||
columnsVisibility?: boolean
|
||||
columnsResizable?: boolean
|
||||
columnsResizeMode?: "onChange" | "onEnd"
|
||||
columnsPinnable?: boolean
|
||||
columnsMovable?: boolean
|
||||
columnsDraggable?: boolean
|
||||
rowsDraggable?: boolean
|
||||
rowsPinnable?: boolean
|
||||
}
|
||||
tableClassNames?: {
|
||||
base?: string
|
||||
header?: string
|
||||
headerRow?: string
|
||||
headerSticky?: string
|
||||
body?: string
|
||||
bodyRow?: string
|
||||
footer?: string
|
||||
edgeCell?: string
|
||||
}
|
||||
}
|
||||
|
||||
const DataGridContext = createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
DataGridContextProps<any> | undefined
|
||||
>(undefined)
|
||||
|
||||
function useDataGrid() {
|
||||
const context = useContext(DataGridContext)
|
||||
if (!context) {
|
||||
throw new Error("useDataGrid must be used within a DataGridProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function DataGridProvider<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData> & { table: Table<TData> }) {
|
||||
const tableState = table.getState()
|
||||
|
||||
// Latest-props ref: context reads always resolve fresh props through the
|
||||
// getter below without the memoized context value depending on unstable
|
||||
// ReactNode/function prop identities (inline emptyMessage/onRowClick would
|
||||
// otherwise publish a new context value on every consumer render - at
|
||||
// mousemove rate during a resize drag, piercing the body-rows memo).
|
||||
const propsRef = useRef(props)
|
||||
propsRef.current = props
|
||||
|
||||
// Re-assert an explicit tableLayout resize mode every render so
|
||||
// consumer-level useReactTable options cannot flip it back between drags.
|
||||
// Without one, the consumer's own tanstack columnResizeMode (default
|
||||
// "onEnd") is honored.
|
||||
if (
|
||||
props.tableLayout?.columnsResizable &&
|
||||
props.tableLayout.columnsResizeMode
|
||||
) {
|
||||
table.options.columnResizeMode = props.tableLayout.columnsResizeMode
|
||||
}
|
||||
|
||||
// One autoSize coordinator per table instance so split header/body viewports
|
||||
// cannot apply the growth twice.
|
||||
const autoSize = useMemo(
|
||||
() => createDataGridAutoSizeController(table),
|
||||
[table]
|
||||
)
|
||||
|
||||
// Memoize context value so consumers don't re-render during column resize.
|
||||
// Column sizing state is intentionally excluded from deps -- CSS variables
|
||||
// on the <table> element handle width updates without React re-renders.
|
||||
// ReactNode/function props (messages, onRowClick) are also excluded: they
|
||||
// are served fresh through the props getter, so unstable inline identities
|
||||
// cannot invalidate the context value.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
get props() {
|
||||
return propsRef.current
|
||||
},
|
||||
table,
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
autoSize,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
table,
|
||||
autoSize,
|
||||
props.recordCount,
|
||||
props.isLoading,
|
||||
props.loadingMode,
|
||||
props.className,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableLayout),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
JSON.stringify(props.tableClassNames),
|
||||
tableState.sorting,
|
||||
tableState.pagination,
|
||||
tableState.columnFilters,
|
||||
tableState.rowSelection,
|
||||
tableState.rowPinning,
|
||||
tableState.expanded,
|
||||
tableState.columnVisibility,
|
||||
tableState.columnOrder,
|
||||
tableState.columnPinning,
|
||||
tableState.globalFilter,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<DataGridContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGrid<TData extends object>({
|
||||
children,
|
||||
table,
|
||||
...props
|
||||
}: DataGridProps<TData>) {
|
||||
const defaultProps: Partial<DataGridProps<TData>> = {
|
||||
loadingMode: "skeleton",
|
||||
tableLayout: {
|
||||
dense: false,
|
||||
cellBorder: false,
|
||||
rowBorder: true,
|
||||
rowRounded: false,
|
||||
stripped: false,
|
||||
headerSticky: false,
|
||||
headerBackground: false,
|
||||
footerBackground: false,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
// columnsResizeMode has no default on purpose: when unset, the
|
||||
// consumer's tanstack columnResizeMode (default "onEnd") is honored.
|
||||
columnsPinnable: false,
|
||||
columnsMovable: false,
|
||||
columnsDraggable: false,
|
||||
rowsDraggable: false,
|
||||
rowsPinnable: false,
|
||||
},
|
||||
tableClassNames: {
|
||||
base: "",
|
||||
header: "",
|
||||
headerRow: "",
|
||||
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
|
||||
body: "",
|
||||
bodyRow: "",
|
||||
footer: "",
|
||||
edgeCell: "",
|
||||
},
|
||||
}
|
||||
|
||||
const mergedProps: DataGridProps<TData> = {
|
||||
...defaultProps,
|
||||
...props,
|
||||
tableLayout: {
|
||||
...defaultProps.tableLayout,
|
||||
...(props.tableLayout || {}),
|
||||
},
|
||||
tableClassNames: {
|
||||
...defaultProps.tableClassNames,
|
||||
...(props.tableClassNames || {}),
|
||||
},
|
||||
}
|
||||
|
||||
// Ensure table is provided
|
||||
if (!table) {
|
||||
throw new Error('DataGrid requires a "table" prop')
|
||||
}
|
||||
|
||||
return (
|
||||
<DataGridProvider table={table} {...mergedProps}>
|
||||
{children}
|
||||
</DataGridProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** Accepted for backwards compatibility; currently has no effect. */
|
||||
border?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn("w-full overflow-hidden", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,59 +1,175 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
/** Minimal Frame surface (ReUI Frame contract) — preview: https://reui.io/docs/components/base/frame */
|
||||
export function Frame({
|
||||
children,
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture for FramePanel theming:
|
||||
*
|
||||
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
|
||||
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
|
||||
* border-(--frame-panel-border-color). This means:
|
||||
*
|
||||
* - variant="inverse" overrides those vars on Frame → all panels pick it up
|
||||
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
|
||||
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
|
||||
* :not() or !important needed
|
||||
*/
|
||||
const frameVariants = cva(
|
||||
[
|
||||
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
|
||||
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
|
||||
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
|
||||
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
|
||||
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
|
||||
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
|
||||
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
|
||||
// Default panel token values — overridden per-variant below
|
||||
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border border-[var(--frame-border-color)] bg-clip-padding",
|
||||
inverse:
|
||||
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
|
||||
ghost: "",
|
||||
},
|
||||
spacing: {
|
||||
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(1)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(1)] (3)] (1)] (3)] (3)]",
|
||||
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(2.5)] (2)] (2)] (2)]",
|
||||
default:
|
||||
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(3)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(3)] (2)] (2)] (2)]",
|
||||
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(4)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(4)] (2)] (2)] (2)]",
|
||||
},
|
||||
stacked: {
|
||||
true: [
|
||||
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
|
||||
"*:has-[+[data-slot=frame-panel]]:before:hidden",
|
||||
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
|
||||
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
|
||||
],
|
||||
false: [
|
||||
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
|
||||
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
|
||||
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
|
||||
],
|
||||
},
|
||||
dense: {
|
||||
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars
|
||||
true: "p-0 gap-0 border-[var(--frame-border-color)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
spacing: "default",
|
||||
stacked: false,
|
||||
dense: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Frame({
|
||||
className,
|
||||
variant,
|
||||
spacing,
|
||||
stacked,
|
||||
dense,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
dense?: boolean
|
||||
}) {
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-card text-card-foreground rounded-xl border shadow-xs',
|
||||
dense ? 'p-3' : 'p-4 md:p-5',
|
||||
className,
|
||||
frameVariants({ variant, spacing, stacked, dense }),
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
data-slot="frame"
|
||||
data-spacing={spacing}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameHeader({
|
||||
children,
|
||||
function FramePanel({
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
fit,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { fit?: boolean }) {
|
||||
return (
|
||||
<div className={cn('mb-3 flex flex-wrap items-start justify-between gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
|
||||
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
|
||||
// via className overrides these by Tailwind source order - no ! needed.
|
||||
"relative overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
|
||||
!fit && "grow",
|
||||
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-radius)-1px)] before:shadow-black/5",
|
||||
"dark:bg-clip-border dark:before:shadow-white/5",
|
||||
"px-(--frame-panel-px) py-(--frame-panel-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameTitle({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <h2 className={cn('text-base font-semibold tracking-tight', className)}>{children}</h2>
|
||||
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FrameDescription({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <p className={cn('text-muted-foreground text-sm', className)}>{children}</p>
|
||||
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-sm font-semibold", className)}
|
||||
data-slot="frame-panel-title"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
data-slot="frame-panel-description"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel-footer"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Frame,
|
||||
FramePanel,
|
||||
FrameHeader,
|
||||
FrameTitle,
|
||||
FrameDescription,
|
||||
FrameFooter,
|
||||
frameVariants,
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
type IconStackProps = React.ComponentProps<"div">
|
||||
|
||||
function IconStack({ className, children, style, ...props }: IconStackProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="icon-stack"
|
||||
className={cn(
|
||||
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--icon-stack-content-x": "71%",
|
||||
"--icon-stack-content-y": "58%",
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 72 81"
|
||||
fill="none"
|
||||
className="h-full w-full overflow-visible"
|
||||
>
|
||||
<ellipse
|
||||
cx="36"
|
||||
cy="76"
|
||||
rx="30"
|
||||
ry="7"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.055"
|
||||
className="blur-[4px]"
|
||||
/>
|
||||
|
||||
<IconStackLayer opacity="0.4" />
|
||||
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
|
||||
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
|
||||
</svg>
|
||||
|
||||
{children ? (
|
||||
<div
|
||||
data-slot="icon-stack-content"
|
||||
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IconStackLayer({
|
||||
active = false,
|
||||
opacity,
|
||||
x = 0,
|
||||
y = 0,
|
||||
}: {
|
||||
active?: boolean
|
||||
opacity: string
|
||||
x?: number
|
||||
y?: number
|
||||
}) {
|
||||
return (
|
||||
<g opacity={opacity} transform={`translate(${x} ${y})`}>
|
||||
<path
|
||||
data-slot="icon-stack-layer"
|
||||
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={active ? "0.3" : "0.2"}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
data-slot="icon-stack-layer"
|
||||
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={active ? "0.3" : "0.2"}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export { IconStack, type IconStackProps }
|
||||
Reference in New Issue
Block a user