fix(config): добавить новые зависимости и обновить конфигурацию компонентов
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m37s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / updater-image (push) Successful in 38s
Docker images / notify-webhook (push) Has been skipped
Docker images / publish-release (push) Successful in 8s
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/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,343 @@
|
||||
"use client"
|
||||
|
||||
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
|
||||
const inputVariants = cva(
|
||||
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
|
||||
default:
|
||||
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
|
||||
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Autocomplete = AutocompletePrimitive.Root
|
||||
|
||||
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteInput({
|
||||
className,
|
||||
size = "default",
|
||||
showClear = false,
|
||||
showTrigger = false,
|
||||
...props
|
||||
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
|
||||
VariantProps<typeof inputVariants> & {
|
||||
showClear?: boolean
|
||||
showTrigger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="relative w-full">
|
||||
<AutocompletePrimitive.Input
|
||||
data-slot="autocomplete-input"
|
||||
data-size={size}
|
||||
className={cn(inputVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
{showTrigger && <AutocompleteTrigger />}
|
||||
{showClear && <AutocompleteClear />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteStatus({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Status.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Status
|
||||
data-slot="autocomplete-status"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteBackdrop({
|
||||
...props
|
||||
}: AutocompletePrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Backdrop
|
||||
data-slot="autocomplete-backdrop"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompletePositioner({
|
||||
className,
|
||||
...props
|
||||
}: AutocompletePrimitive.Positioner.Props) {
|
||||
return (
|
||||
<AutocompletePrimitive.Positioner
|
||||
data-slot="autocomplete-positioner"
|
||||
className={cn("z-50 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteList({
|
||||
className,
|
||||
scrollAreaClassName,
|
||||
...props
|
||||
}: AutocompletePrimitive.List.Props & {
|
||||
scrollAreaClassName?: string
|
||||
scrollFade?: boolean
|
||||
scrollbarGutter?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
|
||||
scrollAreaClassName
|
||||
)}
|
||||
>
|
||||
<AutocompletePrimitive.List
|
||||
data-slot="autocomplete-list"
|
||||
className={cn(
|
||||
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteCollection({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Collection
|
||||
data-slot="autocomplete-collection"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteRow({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Row
|
||||
data-slot="autocomplete-row"
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Item
|
||||
data-slot="autocomplete-item"
|
||||
className={cn(
|
||||
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export interface AutocompleteContentProps extends React.ComponentProps<
|
||||
typeof AutocompletePrimitive.Popup
|
||||
> {
|
||||
align?: AutocompletePrimitive.Positioner.Props["align"]
|
||||
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
|
||||
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
|
||||
side?: AutocompletePrimitive.Positioner.Props["side"]
|
||||
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
|
||||
showBackdrop?: boolean
|
||||
}
|
||||
|
||||
function AutocompleteContent({
|
||||
className,
|
||||
children,
|
||||
showBackdrop = false,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
anchor,
|
||||
...props
|
||||
}: AutocompleteContentProps) {
|
||||
return (
|
||||
<AutocompletePortal>
|
||||
{showBackdrop && <AutocompleteBackdrop />}
|
||||
<AutocompletePositioner
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
anchor={anchor}
|
||||
>
|
||||
<div className="relative flex max-h-full">
|
||||
<AutocompletePrimitive.Popup
|
||||
data-slot="autocomplete-popup"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</AutocompletePrimitive.Popup>
|
||||
</div>
|
||||
</AutocompletePositioner>
|
||||
</AutocompletePortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteGroupLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
|
||||
return (
|
||||
<AutocompletePrimitive.GroupLabel
|
||||
data-slot="autocomplete-group-label"
|
||||
className={cn(
|
||||
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Empty
|
||||
data-slot="autocomplete-empty"
|
||||
className={cn(
|
||||
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteClear({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Clear
|
||||
data-slot="autocomplete-clear"
|
||||
className={cn(
|
||||
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</AutocompletePrimitive.Clear>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Trigger
|
||||
data-slot="autocomplete-trigger"
|
||||
className={cn(
|
||||
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4 opacity-70" />
|
||||
</AutocompletePrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteArrow({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AutocompleteSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
|
||||
return (
|
||||
<AutocompletePrimitive.Separator
|
||||
data-slot="autocomplete-separator"
|
||||
className={cn(
|
||||
"bg-border my-1.5 h-px",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Autocomplete,
|
||||
AutocompleteValue,
|
||||
AutocompleteTrigger,
|
||||
AutocompleteInput,
|
||||
AutocompleteStatus,
|
||||
AutocompletePortal,
|
||||
AutocompleteBackdrop,
|
||||
AutocompletePositioner,
|
||||
AutocompleteContent,
|
||||
AutocompleteList,
|
||||
AutocompleteCollection,
|
||||
AutocompleteRow,
|
||||
AutocompleteItem,
|
||||
AutocompleteGroup,
|
||||
AutocompleteGroupLabel,
|
||||
AutocompleteEmpty,
|
||||
AutocompleteClear,
|
||||
AutocompleteArrow,
|
||||
AutocompleteSeparator,
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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 "@/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":
|
||||
"bg-primary/10 border-none text-primary dark:bg-primary/20",
|
||||
"warning-light":
|
||||
"bg-warning/10 border-none text-warning-foreground dark:bg-warning/20",
|
||||
"success-light":
|
||||
"bg-success/10 border-none text-success-foreground dark:bg-success/20",
|
||||
"info-light":
|
||||
"bg-info/10 border-none text-info-foreground dark:bg-info/20",
|
||||
"destructive-light":
|
||||
"bg-destructive/10 border-none text-destructive-foreground dark:bg-destructive/20",
|
||||
"invert-light":
|
||||
"bg-invert/10 border-none text-foreground dark:bg-invert/20",
|
||||
"focus-light":
|
||||
"bg-focus/10 border-none text-focus-foreground dark:bg-focus/20",
|
||||
"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`: per-theme radius. `full`: max radius per theme (Lyra stays `rounded-none`). */
|
||||
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,167 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Column } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/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 selectedValues = new Set(column?.getFilterValue() 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="rounded-sm px-1 font-normal lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="rounded-sm px-1 font-normal"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="rounded-sm 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)
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm 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 me-2 flex h-4 w-4 items-center justify-center rounded-sm 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 mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<div className="bg-border -mx-1 my-1 h-px" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
onClick={() => column?.setFilterValue(undefined)}
|
||||
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm 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,345 @@
|
||||
"use client"
|
||||
|
||||
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 "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/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
|
||||
pinnable?: boolean
|
||||
filter?: ReactNode
|
||||
visibility?: boolean
|
||||
}
|
||||
|
||||
function DataGridColumnHeaderInner<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
icon,
|
||||
className,
|
||||
filter,
|
||||
visibility = false,
|
||||
}: DataGridColumnHeaderProps<TData, TValue>) {
|
||||
const { isLoading, table, props, recordCount } = useDataGrid()
|
||||
const resolvedTitle = title ?? getColumnHeaderLabel(column)
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const columnVisibilityKey = 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 -ms-2 px-2 font-normal h-6 rounded-lg",
|
||||
className
|
||||
)
|
||||
|
||||
const sortIcon =
|
||||
canSort &&
|
||||
(isSorted === "desc" ? (
|
||||
<ArrowDownIcon className="size-3.25" />
|
||||
) : isSorted === "asc" ? (
|
||||
<ArrowUpIcon className="size-3.25" />
|
||||
) : (
|
||||
<ChevronsUpDownIcon className="mt-px size-3.25" />
|
||||
))
|
||||
|
||||
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="flex h-full items-center justify-between gap-1.5">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
>
|
||||
{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="-me-1 size-7 rounded-md"
|
||||
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="flex h-full items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={headerButtonClassName}
|
||||
disabled={isLoading || recordCount === 0}
|
||||
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,53 @@
|
||||
"use client"
|
||||
|
||||
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 "@/components/ui/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,226 @@
|
||||
"use client"
|
||||
|
||||
import React, { ReactNode } from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/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],
|
||||
sizesLabel: "Show",
|
||||
sizesDescription: "per page",
|
||||
sizesSkeleton: <Skeleton className="h-8 w-44" />,
|
||||
moreLimit: 5,
|
||||
more: false,
|
||||
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 = "size-7 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 = 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
|
||||
.replace("{from}", from.toString())
|
||||
.replace("{to}", to.toString())
|
||||
.replace("{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-14" size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top" className="min-w-18">
|
||||
{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 text-sm order-2 text-nowrap sm:order-1">
|
||||
{paginationInfo}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
<div className="order-1 flex items-center space-x-1 sm:order-2">
|
||||
<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,421 @@
|
||||
"use client"
|
||||
|
||||
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 "@/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
|
||||
|
||||
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 } = 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
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
let frame = 0
|
||||
|
||||
const scheduleSync = () => {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
|
||||
}
|
||||
|
||||
scheduleSync()
|
||||
viewport.addEventListener("scroll", scheduleSync, { passive: true })
|
||||
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(scheduleSync)
|
||||
|
||||
observer?.observe(viewport)
|
||||
observedElementsRef.current.header &&
|
||||
observer?.observe(observedElementsRef.current.header)
|
||||
observedElementsRef.current.table &&
|
||||
observer?.observe(observedElementsRef.current.table)
|
||||
observedElementsRef.current.tableViewport &&
|
||||
observer?.observe(observedElementsRef.current.tableViewport)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
observer?.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="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"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)}
|
||||
|
||||
{showVertical && !usesCustomVerticalScrollbar && (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="data-grid-scrollbar"
|
||||
data-orientation="vertical"
|
||||
orientation="vertical"
|
||||
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"
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="data-grid-thumb"
|
||||
className="bg-border rounded-full relative flex-1"
|
||||
/>
|
||||
</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,309 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
createContext,
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
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,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Cell, flexRender, HeaderGroup, Row } from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/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
|
||||
)}
|
||||
disabled
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</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
|
||||
)}
|
||||
{...context.attributes}
|
||||
{...context.listeners}
|
||||
>
|
||||
<GripHorizontalIcon
|
||||
/>
|
||||
</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}
|
||||
key={row.id}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
|
||||
return (
|
||||
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</DataGridTableBodyRowCell>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
</SortableRowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableDndRows<TData>({
|
||||
handleDragEnd,
|
||||
dataIds,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
dataIds: UniqueIdentifier[]
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingRow, setIsDraggingRow] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
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 headerGroup={headerGroup} key={index}>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
<SortableContext
|
||||
items={dataIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return <DataGridTableDndRow row={row} key={row.id} />
|
||||
})}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows }
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
Fragment,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableBodyRowSkeleton,
|
||||
DataGridTableBodyRowSkeletonCell,
|
||||
DataGridTableEmpty,
|
||||
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,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
Cell,
|
||||
flexRender,
|
||||
Header,
|
||||
HeaderGroup,
|
||||
Row,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { Button } from "@/components/ui/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>
|
||||
)}
|
||||
<span className="grow truncate">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
{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 DataGridTableDnd<TData>({
|
||||
handleDragEnd,
|
||||
footerContent,
|
||||
}: {
|
||||
handleDragEnd: (event: DragEndEvent) => void
|
||||
footerContent?: ReactNode
|
||||
}) {
|
||||
const { table, isLoading, props } = useDataGrid()
|
||||
const pagination = table.getState().pagination
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
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 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 (
|
||||
<DndContext
|
||||
collisionDetection={closestCenter}
|
||||
id={useId()}
|
||||
modifiers={[restrictToTableBounds]}
|
||||
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 headerGroup={headerGroup} key={index}>
|
||||
<SortableContext
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<DataGridTableDndHeader
|
||||
header={header}
|
||||
key={header.id}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DataGridTableHeadRow>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHead>
|
||||
|
||||
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
{props.loadingMode === "skeleton" &&
|
||||
isLoading &&
|
||||
pagination?.pageSize ? (
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRowSkeleton>
|
||||
))
|
||||
) : table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row: Row<TData>) => {
|
||||
return (
|
||||
<Fragment key={row.id}>
|
||||
<DataGridTableBodyRow row={row}>
|
||||
{row
|
||||
.getVisibleCells()
|
||||
.map((cell: Cell<TData, unknown>) => {
|
||||
return (
|
||||
<SortableContext
|
||||
key={cell.id}
|
||||
items={table.getState().columnOrder}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
<DataGridTableDndCell cell={cell} />
|
||||
</SortableContext>
|
||||
)
|
||||
})}
|
||||
</DataGridTableBodyRow>
|
||||
{row.getIsExpanded() && (
|
||||
<DataGridTableBodyRowExpandded row={row} />
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<DataGridTableEmpty />
|
||||
)}
|
||||
</DataGridTableBody>
|
||||
|
||||
{footerContent && (
|
||||
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
|
||||
)}
|
||||
</DataGridTableBase>
|
||||
</DataGridTableViewport>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
export { DataGridTableDnd }
|
||||
@@ -0,0 +1,492 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
|
||||
import {
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableHeadRowCellResize,
|
||||
DataGridTableRenderedRow,
|
||||
DataGridTableRowSpacer,
|
||||
DataGridTableViewport,
|
||||
getDataGridTableRowSections,
|
||||
} from "@/components/reui/data-grid/data-grid-table"
|
||||
import { flexRender, HeaderGroup, Row, Table } from "@tanstack/react-table"
|
||||
import {
|
||||
useVirtualizer,
|
||||
VirtualItem,
|
||||
Virtualizer,
|
||||
VirtualizerOptions,
|
||||
} from "@tanstack/react-virtual"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Spinner } from "@/components/ui/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>
|
||||
columnCount: number
|
||||
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 DataGridTableVirtualSpacer({
|
||||
columnCount,
|
||||
height,
|
||||
}: {
|
||||
columnCount: number
|
||||
height: number
|
||||
}) {
|
||||
if (height <= 0) return null
|
||||
|
||||
return (
|
||||
<tr aria-hidden="true">
|
||||
<td colSpan={columnCount} style={{ height, padding: 0 }} />
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualStatusRow({
|
||||
children,
|
||||
className,
|
||||
columnCount,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
columnCount: number
|
||||
}) {
|
||||
return (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columnCount}
|
||||
className={cn(
|
||||
"text-muted-foreground py-4 text-center text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function DataGridTableVirtualBody<TData>({
|
||||
table,
|
||||
columnCount,
|
||||
topRows,
|
||||
centerRows,
|
||||
bottomRows,
|
||||
virtualItems,
|
||||
totalSize,
|
||||
isVirtualizationEnabled,
|
||||
isInfiniteMode,
|
||||
isFetchingMore,
|
||||
hasMore,
|
||||
loadingMoreMessage,
|
||||
allRowsLoadedMessage,
|
||||
measureRowRef,
|
||||
}: VirtualBodyProps<TData>) {
|
||||
const totalRows = topRows.length + centerRows.length + bottomRows.length
|
||||
|
||||
if (!totalRows) 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"
|
||||
columnCount={columnCount}
|
||||
height={leadingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
virtualItems.forEach((virtualRow) => {
|
||||
const row = centerRows[virtualRow.index]
|
||||
|
||||
if (!row) return
|
||||
|
||||
renderedRows.push(
|
||||
<DataGridTableRenderedRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
rowRef={measureRowRef}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
if (trailingSpacerHeight > 0) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualSpacer
|
||||
key="virtual-spacer-end"
|
||||
columnCount={columnCount}
|
||||
height={trailingSpacerHeight}
|
||||
/>
|
||||
)
|
||||
}
|
||||
} else {
|
||||
centerRows.forEach((row) => {
|
||||
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
|
||||
})
|
||||
}
|
||||
|
||||
if (showFetchingRow) {
|
||||
renderedRows.push(
|
||||
<DataGridTableVirtualStatusRow
|
||||
key="virtual-status-loading"
|
||||
columnCount={columnCount}
|
||||
>
|
||||
<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"
|
||||
columnCount={columnCount}
|
||||
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 { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
|
||||
table,
|
||||
props.tableLayout?.rowsPinnable
|
||||
)
|
||||
const columnCount =
|
||||
table.getVisibleFlatColumns().length +
|
||||
(props.tableLayout?.columnsResizable ? 1 : 0)
|
||||
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?.closest(
|
||||
'[data-slot="scroll-area-viewport"]'
|
||||
) as HTMLElement | null) ?? node,
|
||||
})
|
||||
}, [])
|
||||
|
||||
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 = useMemo(
|
||||
() => Math.max(0, fetchMoreOffset),
|
||||
[fetchMoreOffset]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isVirtualizationEnabled ||
|
||||
!isInfiniteMode ||
|
||||
hasMore === false ||
|
||||
isFetchingMore
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastItem = virtualItems[virtualItems.length - 1]
|
||||
if (!lastItem) return
|
||||
|
||||
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
|
||||
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" }
|
||||
}
|
||||
>
|
||||
<DataGridTableBase>
|
||||
{renderHeader && (
|
||||
<DataGridTableHead>
|
||||
{table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup: HeaderGroup<TData>, index) => (
|
||||
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
|
||||
{headerGroup.headers.map((header, hIndex) => {
|
||||
const { column } = header
|
||||
|
||||
return (
|
||||
<DataGridTableHeadRowCell header={header} key={hIndex}>
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
</DataGridTableHeadRow>
|
||||
))}
|
||||
</DataGridTableHead>
|
||||
)}
|
||||
|
||||
{renderHeader &&
|
||||
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
|
||||
<DataGridTableRowSpacer />
|
||||
)}
|
||||
|
||||
<DataGridTableBody>
|
||||
<MemoizedVirtualBody
|
||||
table={table}
|
||||
columnCount={columnCount}
|
||||
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,270 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, ReactNode, useContext, useMemo } from "react"
|
||||
import {
|
||||
Column,
|
||||
ColumnFiltersState,
|
||||
RowData,
|
||||
SortingState,
|
||||
Table,
|
||||
} from "@tanstack/react-table"
|
||||
|
||||
import { cn } from "@/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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
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
|
||||
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()
|
||||
const resolvedColumnsResizeMode =
|
||||
props.tableLayout?.columnsResizeMode ?? "onEnd"
|
||||
|
||||
// Keep resize mode aligned with the DataGrid contract every render so
|
||||
// consumer-level useReactTable options cannot flip it back between drags.
|
||||
if (props.tableLayout?.columnsResizable) {
|
||||
table.options.columnResizeMode = resolvedColumnsResizeMode
|
||||
}
|
||||
|
||||
// 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.
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
props,
|
||||
table,
|
||||
recordCount: props.recordCount,
|
||||
isLoading: props.isLoading || false,
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
table,
|
||||
props.recordCount,
|
||||
props.isLoading,
|
||||
props.loadingMode,
|
||||
props.loadingMessage,
|
||||
props.fetchingMoreMessage,
|
||||
props.allRowsLoadedMessage,
|
||||
props.emptyMessage,
|
||||
props.onRowClick,
|
||||
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.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: true,
|
||||
headerBorder: true,
|
||||
width: "fixed",
|
||||
columnsVisibility: false,
|
||||
columnsResizable: false,
|
||||
columnsResizeMode: "onEnd",
|
||||
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,
|
||||
border = true,
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
border?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="data-grid"
|
||||
className={cn(
|
||||
"w-full overflow-hidden",
|
||||
border &&
|
||||
"border-border rounded-lg border",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
|
||||
@@ -0,0 +1,36 @@
|
||||
export {
|
||||
DataGrid,
|
||||
DataGridContainer,
|
||||
DataGridProvider,
|
||||
useDataGrid,
|
||||
getColumnHeaderLabel,
|
||||
type DataGridProps,
|
||||
type DataGridApiFetchParams,
|
||||
type DataGridApiResponse,
|
||||
} from "./data-grid"
|
||||
export { DataGridColumnFilter } from "./data-grid-column-filter"
|
||||
export { DataGridColumnHeader } from "./data-grid-column-header"
|
||||
export { DataGridColumnVisibility } from "./data-grid-column-visibility"
|
||||
export { DataGridPagination } from "./data-grid-pagination"
|
||||
export { DataGridScrollArea } from "./data-grid-scroll-area"
|
||||
export { DataGridTableDndRowHandle, DataGridTableDndRows } from "./data-grid-table-dnd-rows"
|
||||
export { DataGridTableDnd } from "./data-grid-table-dnd"
|
||||
export { DataGridTableVirtual } from "./data-grid-table-virtual"
|
||||
export {
|
||||
DataGridTable,
|
||||
DataGridTableBase,
|
||||
DataGridTableBody,
|
||||
DataGridTableBodyRow,
|
||||
DataGridTableBodyRowCell,
|
||||
DataGridTableBodyRowExpandded,
|
||||
DataGridTableEmpty,
|
||||
DataGridTableFoot,
|
||||
DataGridTableFootRow,
|
||||
DataGridTableFootRowCell,
|
||||
DataGridTableHead,
|
||||
DataGridTableHeadRow,
|
||||
DataGridTableHeadRowCell,
|
||||
DataGridTableRowSelect,
|
||||
DataGridTableRowSelectAll,
|
||||
getDataGridTableRowSections,
|
||||
} from "./data-grid-table"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/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-0.75 p-0.75 rounded-(--frame-radius)",
|
||||
"[--frame-radius:var(--radius-xl)]",
|
||||
// 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-p:--spacing(2)] [--frame-panel-header-px:--spacing(2)] [--frame-panel-header-py:--spacing(1)] [--frame-panel-footer-px:--spacing(2)] [--frame-panel-footer-py:--spacing(1)]",
|
||||
sm: "[--frame-panel-p:--spacing(3)] [--frame-panel-header-px:--spacing(3)] [--frame-panel-header-py:--spacing(2)] [--frame-panel-footer-px:--spacing(3)] [--frame-panel-footer-py:--spacing(2)]",
|
||||
default:
|
||||
"[--frame-panel-p:--spacing(4)] [--frame-panel-header-px:--spacing(4)] [--frame-panel-header-py:--spacing(3)] [--frame-panel-footer-px:--spacing(4)] [--frame-panel-footer-py:--spacing(3)]",
|
||||
lg: "[--frame-panel-p:--spacing(5)] [--frame-panel-header-px:--spacing(5)] [--frame-panel-header-py:--spacing(4)] [--frame-panel-footer-px:--spacing(5)] [--frame-panel-footer-py:--spacing(4)]",
|
||||
},
|
||||
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",
|
||||
// No FrameHeader present: first panel sits flush against the outer frame border
|
||||
"[&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]: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",
|
||||
false: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
spacing: "default",
|
||||
stacked: false,
|
||||
dense: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Frame({
|
||||
className,
|
||||
variant,
|
||||
spacing,
|
||||
stacked,
|
||||
dense,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
frameVariants({ variant, spacing, stacked, dense }),
|
||||
className
|
||||
)}
|
||||
data-slot="frame"
|
||||
data-spacing={spacing}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FramePanel({
|
||||
className,
|
||||
fit,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { fit?: boolean }) {
|
||||
return (
|
||||
<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 grow overflow-hidden rounded-(--frame-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
|
||||
"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",
|
||||
"p-(--frame-panel-p)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"flex flex-col px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
|
||||
className
|
||||
)}
|
||||
data-slot="frame-panel-header"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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-1 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,260 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, ReactNode, useContext, useId } from "react"
|
||||
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
|
||||
import { cva, VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { MinusIcon, PlusIcon } from "lucide-react"
|
||||
|
||||
const NumberFieldContext = createContext<{
|
||||
fieldId: string
|
||||
size: "sm" | "default" | "lg"
|
||||
} | null>(null)
|
||||
|
||||
const numberFieldGroupVariants = cva(
|
||||
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "h-7 text-sm",
|
||||
default:
|
||||
"h-8 text-sm",
|
||||
lg: "h-9 text-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const numberFieldButtonVariants = cva(
|
||||
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
default:
|
||||
"px-2 [&_svg:not([class*='size-'])]:size-4",
|
||||
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const numberFieldInputVariants = cva(
|
||||
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: "px-2 py-0.5",
|
||||
default:
|
||||
"px-2.5 py-1",
|
||||
lg: "px-2.5 py-1.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function NumberField({
|
||||
id,
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: NumberFieldPrimitive.Root.Props &
|
||||
VariantProps<typeof numberFieldGroupVariants>) {
|
||||
const generatedId = useId()
|
||||
const fieldId = id ?? generatedId
|
||||
const sizeValue = size ?? "default"
|
||||
|
||||
return (
|
||||
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
|
||||
<NumberFieldPrimitive.Root
|
||||
className={cn("flex w-full flex-col items-start gap-2", className)}
|
||||
data-size={sizeValue}
|
||||
data-slot="number-field"
|
||||
id={fieldId}
|
||||
{...props}
|
||||
/>
|
||||
</NumberFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldGroup({
|
||||
className,
|
||||
size: sizeProp,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Group.Props &
|
||||
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldGroup must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Group
|
||||
className={cn(numberFieldGroupVariants({ size }), className)}
|
||||
data-slot="number-field-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldDecrement({
|
||||
className,
|
||||
size: sizeProp,
|
||||
children,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Decrement.Props &
|
||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldDecrement must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Decrement
|
||||
className={cn(
|
||||
numberFieldButtonVariants({ size }),
|
||||
"rounded-s-lg border-e-0",
|
||||
className
|
||||
)}
|
||||
data-slot="number-field-decrement"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<MinusIcon
|
||||
/>
|
||||
)}
|
||||
</NumberFieldPrimitive.Decrement>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldIncrement({
|
||||
className,
|
||||
size: sizeProp,
|
||||
children,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Increment.Props &
|
||||
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
|
||||
children?: ReactNode
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldIncrement must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Increment
|
||||
className={cn(
|
||||
numberFieldButtonVariants({ size }),
|
||||
"rounded-e-lg border-s-0",
|
||||
className
|
||||
)}
|
||||
data-slot="number-field-increment"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<PlusIcon
|
||||
/>
|
||||
)}
|
||||
</NumberFieldPrimitive.Increment>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldInput({
|
||||
className,
|
||||
size: sizeProp,
|
||||
...props
|
||||
}: NumberFieldPrimitive.Input.Props &
|
||||
Partial<VariantProps<typeof numberFieldInputVariants>>) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldInput must be used within a NumberField component."
|
||||
)
|
||||
}
|
||||
const size = sizeProp ?? context.size
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.Input
|
||||
className={cn(numberFieldInputVariants({ size }), className)}
|
||||
data-slot="number-field-input"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NumberFieldScrubArea({
|
||||
className,
|
||||
label,
|
||||
...props
|
||||
}: NumberFieldPrimitive.ScrubArea.Props & {
|
||||
label: string
|
||||
}) {
|
||||
const context = useContext(NumberFieldContext)
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberFieldPrimitive.ScrubArea
|
||||
className={cn("flex cursor-ew-resize", className)}
|
||||
data-slot="number-field-scrub-area"
|
||||
{...props}
|
||||
>
|
||||
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
|
||||
{label}
|
||||
</Label>
|
||||
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
|
||||
<CursorGrowIcon />
|
||||
</NumberFieldPrimitive.ScrubAreaCursor>
|
||||
</NumberFieldPrimitive.ScrubArea>
|
||||
)
|
||||
}
|
||||
|
||||
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
fill="black"
|
||||
height="14"
|
||||
stroke="white"
|
||||
viewBox="0 0 24 14"
|
||||
width="26"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NumberField,
|
||||
NumberFieldScrubArea,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldInput,
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
createContext,
|
||||
CSSProperties,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import {
|
||||
defaultDropAnimationSideEffects,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
Modifiers,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DraggableSyntheticListeners,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
defaultAnimateLayoutChanges,
|
||||
rectSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
type AnimateLayoutChanges,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Sortable Item Context
|
||||
const SortableItemContext = createContext<{
|
||||
listeners: DraggableSyntheticListeners | undefined
|
||||
isDragging?: boolean
|
||||
disabled?: boolean
|
||||
}>({
|
||||
listeners: undefined,
|
||||
isDragging: false,
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const IsOverlayContext = createContext(false)
|
||||
|
||||
const SortableInternalContext = createContext<{
|
||||
activeId: UniqueIdentifier | null
|
||||
modifiers?: Modifiers
|
||||
}>({
|
||||
activeId: null,
|
||||
modifiers: undefined,
|
||||
})
|
||||
|
||||
const animateLayoutChanges: AnimateLayoutChanges = (args) =>
|
||||
defaultAnimateLayoutChanges({ ...args, wasDragging: true })
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
sideEffects: defaultDropAnimationSideEffects({
|
||||
styles: {
|
||||
active: {
|
||||
opacity: "0.4",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
// Multipurpose Sortable Component
|
||||
export interface SortableRootProps<T> extends Omit<
|
||||
useRender.ComponentProps<"div">,
|
||||
"onDragStart" | "onDragEnd" | "children"
|
||||
> {
|
||||
value: T[]
|
||||
onValueChange: (value: T[]) => void
|
||||
getItemValue: (item: T) => string
|
||||
children: ReactNode
|
||||
onMove?: (event: {
|
||||
event: DragEndEvent
|
||||
activeIndex: number
|
||||
overIndex: number
|
||||
}) => void
|
||||
strategy?: "horizontal" | "vertical" | "grid"
|
||||
onDragStart?: (event: DragStartEvent) => void
|
||||
onDragEnd?: (event: DragEndEvent) => void
|
||||
modifiers?: Modifiers
|
||||
}
|
||||
|
||||
function Sortable<T>({
|
||||
value,
|
||||
onValueChange,
|
||||
getItemValue,
|
||||
className,
|
||||
render,
|
||||
onMove,
|
||||
strategy = "vertical",
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
modifiers,
|
||||
children,
|
||||
...props
|
||||
}: SortableRootProps<T>) {
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useLayoutEffect(() => setMounted(true), [])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
)
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
setActiveId(event.active.id)
|
||||
onDragStart?.(event)
|
||||
},
|
||||
[onDragStart]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
setActiveId(null)
|
||||
onDragEnd?.(event)
|
||||
|
||||
if (!over) return
|
||||
|
||||
// Handle item reordering
|
||||
const activeIndex = value.findIndex(
|
||||
(item: T) => getItemValue(item) === active.id
|
||||
)
|
||||
const overIndex = value.findIndex(
|
||||
(item: T) => getItemValue(item) === over.id
|
||||
)
|
||||
|
||||
if (activeIndex !== overIndex) {
|
||||
if (onMove) {
|
||||
onMove({ event, activeIndex, overIndex })
|
||||
} else {
|
||||
const newValue = arrayMove(value, activeIndex, overIndex)
|
||||
onValueChange(newValue)
|
||||
}
|
||||
}
|
||||
},
|
||||
[value, getItemValue, onValueChange, onMove, onDragEnd]
|
||||
)
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveId(null)
|
||||
}, [])
|
||||
|
||||
const getStrategy = () => {
|
||||
switch (strategy) {
|
||||
case "horizontal":
|
||||
return rectSortingStrategy
|
||||
case "grid":
|
||||
return rectSortingStrategy
|
||||
case "vertical":
|
||||
default:
|
||||
return verticalListSortingStrategy
|
||||
}
|
||||
}
|
||||
|
||||
const itemIds = useMemo(() => value.map(getItemValue), [value, getItemValue])
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ activeId, modifiers }),
|
||||
[activeId, modifiers]
|
||||
)
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "sortable",
|
||||
"data-dragging": activeId !== null,
|
||||
className: cn(activeId !== null && "cursor-grabbing!", className),
|
||||
children,
|
||||
}
|
||||
|
||||
// Find the active child for the overlay
|
||||
const overlayContent = useMemo(() => {
|
||||
if (!activeId) return null
|
||||
let result: ReactNode = null
|
||||
Children.forEach(children, (child) => {
|
||||
if (isValidElement(child) && (child.props as any).value === activeId) {
|
||||
result = cloneElement(child as ReactElement<any>, {
|
||||
...(child.props as any),
|
||||
className: cn((child.props as any).className, "z-50"),
|
||||
})
|
||||
}
|
||||
})
|
||||
return result
|
||||
}, [activeId, children])
|
||||
|
||||
return (
|
||||
<SortableInternalContext.Provider value={contextValue}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={modifiers}
|
||||
measuring={{
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
}}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<SortableContext items={itemIds} strategy={getStrategy()}>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</SortableContext>
|
||||
{mounted &&
|
||||
createPortal(
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimationConfig}
|
||||
modifiers={modifiers}
|
||||
className={cn("z-50", activeId && "cursor-grabbing")}
|
||||
>
|
||||
<IsOverlayContext.Provider value={true}>
|
||||
{overlayContent}
|
||||
</IsOverlayContext.Provider>
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
</DndContext>
|
||||
</SortableInternalContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SortableItemProps extends useRender.ComponentProps<"div"> {
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function SortableItem({
|
||||
value,
|
||||
className,
|
||||
render,
|
||||
disabled,
|
||||
...props
|
||||
}: SortableItemProps) {
|
||||
const isOverlay = useContext(IsOverlayContext)
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
attributes,
|
||||
listeners,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({
|
||||
id: value,
|
||||
disabled: disabled || isOverlay,
|
||||
animateLayoutChanges,
|
||||
})
|
||||
|
||||
if (isOverlay) {
|
||||
const defaultProps = {
|
||||
"data-slot": "sortable-item",
|
||||
"data-value": value,
|
||||
"data-dragging": true,
|
||||
className: cn(className),
|
||||
children: props.children,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider
|
||||
value={{ listeners: undefined, isDragging: true, disabled: false }}
|
||||
>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</SortableItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const style = {
|
||||
transition,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
} as CSSProperties
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "sortable-item",
|
||||
"data-value": value,
|
||||
"data-dragging": isSortableDragging,
|
||||
"data-disabled": disabled,
|
||||
ref: setNodeRef,
|
||||
style,
|
||||
...attributes,
|
||||
className: cn(
|
||||
isSortableDragging && "opacity-50 z-50",
|
||||
disabled && "opacity-50",
|
||||
className
|
||||
),
|
||||
children: props.children,
|
||||
}
|
||||
|
||||
return (
|
||||
<SortableItemContext.Provider
|
||||
value={{ listeners, isDragging: isSortableDragging, disabled }}
|
||||
>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</SortableItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export interface SortableItemHandleProps extends useRender.ComponentProps<"div"> {
|
||||
cursor?: boolean
|
||||
}
|
||||
|
||||
function SortableItemHandle({
|
||||
className,
|
||||
render,
|
||||
cursor = true,
|
||||
...props
|
||||
}: SortableItemHandleProps) {
|
||||
const { listeners, isDragging, disabled } = useContext(SortableItemContext)
|
||||
|
||||
const defaultProps = {
|
||||
"data-slot": "sortable-item-handle",
|
||||
"data-dragging": isDragging,
|
||||
"data-disabled": disabled,
|
||||
...listeners,
|
||||
className: cn(
|
||||
cursor && (isDragging ? "cursor-grabbing!" : "cursor-grab!"),
|
||||
className
|
||||
),
|
||||
children: props.children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export interface SortableOverlayProps extends Omit<
|
||||
React.ComponentProps<typeof DragOverlay>,
|
||||
"children"
|
||||
> {
|
||||
children?: ReactNode | ((params: { value: UniqueIdentifier }) => ReactNode)
|
||||
}
|
||||
|
||||
function SortableOverlay({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: SortableOverlayProps) {
|
||||
const { activeId, modifiers } = useContext(SortableInternalContext)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useLayoutEffect(() => setMounted(true), [])
|
||||
|
||||
const content =
|
||||
activeId && children
|
||||
? typeof children === "function"
|
||||
? children({ value: activeId })
|
||||
: children
|
||||
: null
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return createPortal(
|
||||
<DragOverlay
|
||||
dropAnimation={dropAnimationConfig}
|
||||
modifiers={modifiers}
|
||||
className={cn("z-50", activeId && "cursor-grabbing", className)}
|
||||
{...props}
|
||||
>
|
||||
<IsOverlayContext.Provider value={true}>
|
||||
{content}
|
||||
</IsOverlayContext.Provider>
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
export { Sortable, SortableItem, SortableItemHandle, SortableOverlay }
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
Children,
|
||||
createContext,
|
||||
HTMLAttributes,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Types
|
||||
type StepperOrientation = "horizontal" | "vertical"
|
||||
type StepState = "active" | "completed" | "inactive" | "loading"
|
||||
type StepIndicators = {
|
||||
active?: React.ReactNode
|
||||
completed?: React.ReactNode
|
||||
inactive?: React.ReactNode
|
||||
loading?: React.ReactNode
|
||||
}
|
||||
|
||||
interface StepperContextValue {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
stepsCount: number
|
||||
orientation: StepperOrientation
|
||||
registerTrigger: (node: HTMLButtonElement | null) => void
|
||||
triggerNodes: HTMLButtonElement[]
|
||||
focusNext: (currentIdx: number) => void
|
||||
focusPrev: (currentIdx: number) => void
|
||||
focusFirst: () => void
|
||||
focusLast: () => void
|
||||
indicators: StepIndicators
|
||||
}
|
||||
|
||||
interface StepItemContextValue {
|
||||
step: number
|
||||
state: StepState
|
||||
isDisabled: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const StepperContext = createContext<StepperContextValue | undefined>(undefined)
|
||||
const StepItemContext = createContext<StepItemContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
function useStepper() {
|
||||
const ctx = useContext(StepperContext)
|
||||
if (!ctx) throw new Error("useStepper must be used within a Stepper")
|
||||
return ctx
|
||||
}
|
||||
|
||||
function useStepItem() {
|
||||
const ctx = useContext(StepItemContext)
|
||||
if (!ctx) throw new Error("useStepItem must be used within a StepperItem")
|
||||
return ctx
|
||||
}
|
||||
|
||||
interface StepperProps extends HTMLAttributes<HTMLDivElement> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: StepperOrientation
|
||||
indicators?: StepIndicators
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "horizontal",
|
||||
className,
|
||||
children,
|
||||
indicators = {},
|
||||
...props
|
||||
}: StepperProps) {
|
||||
const [activeStep, setActiveStep] = useState(defaultValue)
|
||||
const [triggerNodes, setTriggerNodes] = useState<HTMLButtonElement[]>([])
|
||||
|
||||
// Register/unregister triggers
|
||||
const registerTrigger = useCallback((node: HTMLButtonElement | null) => {
|
||||
setTriggerNodes((prev) => {
|
||||
if (node && !prev.includes(node)) {
|
||||
return [...prev, node]
|
||||
} else if (!node && prev.includes(node!)) {
|
||||
return prev.filter((n) => n !== node)
|
||||
} else {
|
||||
return prev
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSetActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setActiveStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
// Keyboard navigation logic
|
||||
const focusTrigger = (idx: number) => {
|
||||
if (triggerNodes[idx]) triggerNodes[idx].focus()
|
||||
}
|
||||
const focusNext = (currentIdx: number) =>
|
||||
focusTrigger((currentIdx + 1) % triggerNodes.length)
|
||||
const focusPrev = (currentIdx: number) =>
|
||||
focusTrigger((currentIdx - 1 + triggerNodes.length) % triggerNodes.length)
|
||||
const focusFirst = () => focusTrigger(0)
|
||||
const focusLast = () => focusTrigger(triggerNodes.length - 1)
|
||||
|
||||
// Context value
|
||||
const contextValue = useMemo<StepperContextValue>(
|
||||
() => ({
|
||||
activeStep: currentStep,
|
||||
setActiveStep: handleSetActiveStep,
|
||||
stepsCount: Children.toArray(children).filter(
|
||||
(child): child is ReactElement =>
|
||||
isValidElement(child) &&
|
||||
(child.type as { displayName?: string }).displayName === "StepperItem"
|
||||
).length,
|
||||
orientation,
|
||||
registerTrigger,
|
||||
focusNext,
|
||||
focusPrev,
|
||||
focusFirst,
|
||||
focusLast,
|
||||
triggerNodes,
|
||||
indicators,
|
||||
}),
|
||||
[
|
||||
currentStep,
|
||||
handleSetActiveStep,
|
||||
children,
|
||||
orientation,
|
||||
registerTrigger,
|
||||
triggerNodes,
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<StepperContext.Provider value={contextValue}>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-orientation={orientation}
|
||||
data-slot="stepper"
|
||||
className={cn("w-full", className)}
|
||||
data-orientation={orientation}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepperContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepperItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
step: number
|
||||
completed?: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function StepperItem({
|
||||
step,
|
||||
completed = false,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StepperItemProps) {
|
||||
const { activeStep } = useStepper()
|
||||
|
||||
const state: StepState =
|
||||
completed || step < activeStep
|
||||
? "completed"
|
||||
: activeStep === step
|
||||
? "active"
|
||||
: "inactive"
|
||||
|
||||
const isLoading = loading && step === activeStep
|
||||
|
||||
return (
|
||||
<StepItemContext.Provider
|
||||
value={{ step, state, isDisabled: disabled, isLoading }}
|
||||
>
|
||||
<div
|
||||
data-slot="stepper-item"
|
||||
className={cn(
|
||||
"group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col",
|
||||
className
|
||||
)}
|
||||
data-state={state}
|
||||
{...(isLoading ? { "data-loading": true } : {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StepItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepperTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
function StepperTrigger({
|
||||
asChild = false,
|
||||
className,
|
||||
children,
|
||||
tabIndex,
|
||||
...props
|
||||
}: StepperTriggerProps) {
|
||||
const { state, isLoading } = useStepItem()
|
||||
const stepperCtx = useStepper()
|
||||
const {
|
||||
setActiveStep,
|
||||
activeStep,
|
||||
registerTrigger,
|
||||
triggerNodes,
|
||||
focusNext,
|
||||
focusPrev,
|
||||
focusFirst,
|
||||
focusLast,
|
||||
} = stepperCtx
|
||||
const { step, isDisabled } = useStepItem()
|
||||
const isSelected = activeStep === step
|
||||
const id = `stepper-tab-${step}`
|
||||
const panelId = `stepper-panel-${step}`
|
||||
|
||||
// Register this trigger for keyboard navigation
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
useEffect(() => {
|
||||
if (btnRef.current) {
|
||||
registerTrigger(btnRef.current)
|
||||
}
|
||||
}, [btnRef.current])
|
||||
|
||||
// Find our index among triggers for navigation
|
||||
const myIdx = useMemo(
|
||||
() =>
|
||||
triggerNodes.findIndex((n: HTMLButtonElement) => n === btnRef.current),
|
||||
[triggerNodes, btnRef.current]
|
||||
)
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
switch (e.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
if (myIdx !== -1 && focusNext) focusNext(myIdx)
|
||||
break
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
if (myIdx !== -1 && focusPrev) focusPrev(myIdx)
|
||||
break
|
||||
case "Home":
|
||||
e.preventDefault()
|
||||
if (focusFirst) focusFirst()
|
||||
break
|
||||
case "End":
|
||||
e.preventDefault()
|
||||
if (focusLast) focusLast()
|
||||
break
|
||||
case "Enter":
|
||||
case " ":
|
||||
e.preventDefault()
|
||||
setActiveStep(step)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (asChild) {
|
||||
return (
|
||||
<span
|
||||
data-slot="stepper-trigger"
|
||||
data-state={state}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={btnRef}
|
||||
role="tab"
|
||||
id={id}
|
||||
aria-selected={isSelected}
|
||||
aria-controls={panelId}
|
||||
tabIndex={typeof tabIndex === "number" ? tabIndex : isSelected ? 0 : -1}
|
||||
data-slot="stepper-trigger"
|
||||
data-state={state}
|
||||
data-loading={isLoading}
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60",
|
||||
"gap-2.5 rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={() => setActiveStep(step)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperIndicator({
|
||||
children,
|
||||
className,
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { state, isLoading } = useStepItem()
|
||||
const { indicators } = useStepper()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-indicator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden",
|
||||
"rounded-full text-xs",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="absolute">
|
||||
{indicators &&
|
||||
((isLoading && indicators.loading) ||
|
||||
(state === "completed" && indicators.completed) ||
|
||||
(state === "active" && indicators.active) ||
|
||||
(state === "inactive" && indicators.inactive))
|
||||
? (isLoading && indicators.loading) ||
|
||||
(state === "completed" && indicators.completed) ||
|
||||
(state === "active" && indicators.active) ||
|
||||
(state === "inactive" && indicators.inactive)
|
||||
: children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperSeparator({ className }: React.ComponentProps<"div">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-separator"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"bg-muted rounded-sm group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5 m-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperTitle({ children, className }: React.ComponentProps<"h3">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<h3
|
||||
data-slot="stepper-title"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"text-sm leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperDescription({
|
||||
children,
|
||||
className,
|
||||
}: React.ComponentProps<"div">) {
|
||||
const { state } = useStepItem()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-description"
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperNav({ children, className }: React.ComponentProps<"nav">) {
|
||||
const { activeStep, orientation } = useStepper()
|
||||
|
||||
return (
|
||||
<nav
|
||||
data-slot="stepper-nav"
|
||||
data-state={activeStep}
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function StepperPanel({ children, className }: React.ComponentProps<"div">) {
|
||||
const { activeStep } = useStepper()
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-panel"
|
||||
data-state={activeStep}
|
||||
className={cn("w-full", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface StepperContentProps extends React.ComponentProps<"div"> {
|
||||
value: number
|
||||
forceMount?: boolean
|
||||
}
|
||||
|
||||
function StepperContent({
|
||||
value,
|
||||
forceMount,
|
||||
children,
|
||||
className,
|
||||
}: StepperContentProps) {
|
||||
const { activeStep } = useStepper()
|
||||
const isActive = value === activeStep
|
||||
|
||||
if (!forceMount && !isActive) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="stepper-content"
|
||||
data-state={activeStep}
|
||||
className={cn("w-full", className, !isActive && forceMount && "hidden")}
|
||||
hidden={!isActive && forceMount}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useStepper,
|
||||
useStepItem,
|
||||
Stepper,
|
||||
StepperItem,
|
||||
StepperTrigger,
|
||||
StepperIndicator,
|
||||
StepperSeparator,
|
||||
StepperTitle,
|
||||
StepperDescription,
|
||||
StepperPanel,
|
||||
StepperContent,
|
||||
StepperNav,
|
||||
type StepperProps,
|
||||
type StepperItemProps,
|
||||
type StepperTriggerProps,
|
||||
type StepperContentProps,
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client"
|
||||
|
||||
import { createContext, useCallback, useContext, useState } from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Types
|
||||
type TimelineContextValue = {
|
||||
activeStep: number
|
||||
setActiveStep: (step: number) => void
|
||||
}
|
||||
|
||||
// Context
|
||||
const TimelineContext = createContext<TimelineContextValue | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const useTimeline = () => {
|
||||
const context = useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error("useTimeline must be used within a Timeline")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Components
|
||||
interface TimelineProps extends useRender.ComponentProps<"div"> {
|
||||
defaultValue?: number
|
||||
value?: number
|
||||
onValueChange?: (value: number) => void
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
|
||||
function Timeline({
|
||||
defaultValue = 1,
|
||||
value,
|
||||
onValueChange,
|
||||
orientation = "vertical",
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineProps) {
|
||||
const [activeStep, setInternalStep] = useState(defaultValue)
|
||||
|
||||
const setActiveStep = useCallback(
|
||||
(step: number) => {
|
||||
if (value === undefined) {
|
||||
setInternalStep(step)
|
||||
}
|
||||
onValueChange?.(step)
|
||||
},
|
||||
[value, onValueChange]
|
||||
)
|
||||
|
||||
const currentStep = value ?? activeStep
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
),
|
||||
"data-orientation": orientation,
|
||||
"data-slot": "timeline",
|
||||
children,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider
|
||||
value={{ activeStep: currentStep, setActiveStep }}
|
||||
>
|
||||
{useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})}
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// TimelineContent
|
||||
function TimelineContent({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn("text-muted-foreground text-sm", className),
|
||||
"data-slot": "timeline-content",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineDate
|
||||
type TimelineDateProps = useRender.ComponentProps<"time">
|
||||
|
||||
function TimelineDate({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineDateProps) {
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-date",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "time",
|
||||
render,
|
||||
props: mergeProps<"time">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineHeader
|
||||
function TimelineHeader({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
className: cn(className),
|
||||
"data-slot": "timeline-header",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineIndicator
|
||||
type TimelineIndicatorProps = useRender.ComponentProps<"div">
|
||||
|
||||
function TimelineIndicator({
|
||||
className,
|
||||
children,
|
||||
render,
|
||||
...props
|
||||
}: TimelineIndicatorProps) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-indicator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineItem
|
||||
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
|
||||
step: number
|
||||
}
|
||||
|
||||
function TimelineItem({
|
||||
step,
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: TimelineItemProps) {
|
||||
const { activeStep } = useTimeline()
|
||||
|
||||
const defaultProps = {
|
||||
className: cn(
|
||||
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
|
||||
className
|
||||
),
|
||||
"data-completed": step <= activeStep || undefined,
|
||||
"data-slot": "timeline-item",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineSeparator
|
||||
function TimelineSeparator({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div">) {
|
||||
const defaultProps = {
|
||||
"aria-hidden": true,
|
||||
className: cn(
|
||||
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
|
||||
className
|
||||
),
|
||||
"data-slot": "timeline-separator",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
render,
|
||||
props: mergeProps<"div">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
// TimelineTitle
|
||||
function TimelineTitle({
|
||||
className,
|
||||
render,
|
||||
children,
|
||||
...props
|
||||
}: useRender.ComponentProps<"h3">) {
|
||||
const defaultProps = {
|
||||
className: cn("font-medium text-sm", className),
|
||||
"data-slot": "timeline-title",
|
||||
children,
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "h3",
|
||||
render,
|
||||
props: mergeProps<"h3">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineContent,
|
||||
TimelineDate,
|
||||
TimelineHeader,
|
||||
TimelineIndicator,
|
||||
TimelineItem,
|
||||
TimelineSeparator,
|
||||
TimelineTitle,
|
||||
}
|
||||
Reference in New Issue
Block a user