feat(ui,dashboard): Add DataTable block (#10024)

**What**
- Adds opinionated DataTable block to `@medusajs/ui` 
- Adds new DataTable to `@medusajs/dashboard` that uses the above mentioned block as the primitive.

The PR also replaces the table on /customer-groups and the variants table on /products/:id with the new DataTable, to provide an example of it's usage. The previous DataTable component has been renamed to `_DataTable` and has been deprecated.

**Note**
This PR has a lot of LOC. 5,346 of these changes are the fr.json file, which wasn't formatted correctly before. When adding the new translations needed for this PR the file was formatted which caused each line to change to have the proper indentation.

Resolves CMRC-333
This commit is contained in:
Kasper Fabricius Kristensen
2025-01-20 14:26:12 +01:00
committed by GitHub
parent c3976a312b
commit 147c0e5a35
130 changed files with 9238 additions and 3884 deletions

View File

@@ -42,6 +42,7 @@
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@faker-js/faker": "^9.2.0",
"@medusajs/ui-preset": "^2.2.0",
"@storybook/addon-essentials": "^8.3.5",
"@storybook/addon-interactions": "^8.3.5",
@@ -96,6 +97,7 @@
"@radix-ui/react-switch": "1.1.0",
"@radix-ui/react-tabs": "1.1.0",
"@radix-ui/react-tooltip": "1.1.2",
"@tanstack/react-table": "8.20.5",
"clsx": "^1.2.1",
"copy-to-clipboard": "^3.3.3",
"cva": "1.0.0-beta.1",

View File

@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import { EllipsisHorizontal } from "@medusajs/icons"
import { CellContext } from "@tanstack/react-table"
import { DropdownMenu } from "../../../components/dropdown-menu"
import { IconButton } from "../../../components/icon-button"
import { DataTableActionColumnDefMeta } from "../types"
interface DataTableActionCellProps<TData> {
ctx: CellContext<TData, unknown>
}
const DataTableActionCell = <TData,>({
ctx,
}: DataTableActionCellProps<TData>) => {
const meta = ctx.column.columnDef.meta as
| DataTableActionColumnDefMeta<TData>
| undefined
const actions = meta?.___actions
if (!actions) {
return null
}
const resolvedActions = typeof actions === "function" ? actions(ctx) : actions
if (!Array.isArray(resolvedActions)) {
return null
}
return (
<DropdownMenu>
<DropdownMenu.Trigger asChild className="ml-1">
<IconButton size="small" variant="transparent">
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content side="bottom">
{resolvedActions.map((actionOrGroup, idx) => {
const isArray = Array.isArray(actionOrGroup)
const isLast = idx === resolvedActions.length - 1
return isArray ? (
<React.Fragment key={idx}>
{actionOrGroup.map((action) => (
<DropdownMenu.Item
key={action.label}
onClick={(e) => {
e.stopPropagation()
action.onClick(ctx)
}}
className="[&>svg]:text-ui-fg-subtle flex items-center gap-2"
>
{action.icon}
{action.label}
</DropdownMenu.Item>
))}
{!isLast && <DropdownMenu.Separator />}
</React.Fragment>
) : (
<DropdownMenu.Item
key={actionOrGroup.label}
onClick={(e) => {
e.stopPropagation()
actionOrGroup.onClick(ctx)
}}
className="[&>svg]:text-ui-fg-subtle flex items-center gap-2"
>
{actionOrGroup.icon}
{actionOrGroup.label}
</DropdownMenu.Item>
)
})}
</DropdownMenu.Content>
</DropdownMenu>
)
}
export { DataTableActionCell }
export type { DataTableActionCellProps }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
import { CommandBar } from "@/components/command-bar"
interface DataTableCommandBarProps {
selectedLabel?: ((count: number) => string) | string
}
const DataTableCommandBar = (props: DataTableCommandBarProps) => {
const { instance } = useDataTableContext()
const commands = instance.getCommands()
const rowSelection = instance.getRowSelection()
const count = Object.keys(rowSelection || []).length
const open = commands && commands.length > 0 && count > 0
function getSelectedLabel(count: number) {
if (typeof props.selectedLabel === "function") {
return props.selectedLabel(count)
}
return props.selectedLabel
}
if (!commands || commands.length === 0) {
return null
}
return (
<CommandBar open={open}>
<CommandBar.Bar>
{props.selectedLabel && (
<React.Fragment>
<CommandBar.Value>{getSelectedLabel(count)}</CommandBar.Value>
<CommandBar.Seperator />
</React.Fragment>
)}
{commands.map((command, idx) => (
<React.Fragment key={idx}>
<CommandBar.Command
key={command.label}
action={() => command.action(rowSelection)}
label={command.label}
shortcut={command.shortcut}
/>
{idx < commands.length - 1 && <CommandBar.Seperator />}
</React.Fragment>
))}
</CommandBar.Bar>
</CommandBar>
)
}
export { DataTableCommandBar }
export type { DataTableCommandBarProps }

View File

@@ -0,0 +1,72 @@
"use client"
import * as React from "react"
import { DataTableFilter } from "@/blocks/data-table/components/data-table-filter"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
import { Button } from "@/components/button"
import { Skeleton } from "@/components/skeleton"
interface DataTableFilterBarProps {
clearAllFiltersLabel?: string
}
const DataTableFilterBar = ({
clearAllFiltersLabel = "Clear all",
}: DataTableFilterBarProps) => {
const { instance } = useDataTableContext()
const filterState = instance.getFiltering()
const clearFilters = React.useCallback(() => {
instance.clearFilters()
}, [instance])
const filterCount = Object.keys(filterState).length
if (filterCount === 0) {
return null
}
if (instance.showSkeleton) {
return <DataTableFilterBarSkeleton filterCount={filterCount} />
}
return (
<div className="bg-ui-bg-subtle flex w-full flex-nowrap items-center gap-2 overflow-x-auto border-t px-6 py-2 md:flex-wrap">
{Object.entries(filterState).map(([id, filter]) => (
<DataTableFilter key={id} id={id} filter={filter} />
))}
{filterCount > 0 ? (
<Button
variant="transparent"
size="small"
className="text-ui-fg-muted hover:text-ui-fg-subtle flex-shrink-0 whitespace-nowrap"
type="button"
onClick={clearFilters}
>
{clearAllFiltersLabel}
</Button>
) : null}
</div>
)
}
const DataTableFilterBarSkeleton = ({
filterCount,
}: {
filterCount: number
}) => {
return (
<div className="bg-ui-bg-subtle flex w-full flex-nowrap items-center gap-2 overflow-x-auto border-t px-6 py-2 md:flex-wrap">
{Array.from({ length: filterCount }).map((_, index) => (
<Skeleton key={index} className="h-7 w-[180px]" />
))}
{filterCount > 0 ? <Skeleton className="h-7 w-[66px]" /> : null}
</div>
)
}
export { DataTableFilterBar }
export type { DataTableFilterBarProps }

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
import { DropdownMenu } from "@/components/dropdown-menu"
import { IconButton } from "@/components/icon-button"
import { Skeleton } from "@/components/skeleton"
import { Tooltip } from "@/components/tooltip"
import { Funnel } from "@medusajs/icons"
interface DataTableFilterMenuProps {
tooltip?: string
}
const DataTableFilterMenu = (props: DataTableFilterMenuProps) => {
const { instance } = useDataTableContext()
const enabledFilters = Object.keys(instance.getFiltering())
const filterOptions = instance
.getFilters()
.filter((filter) => !enabledFilters.includes(filter.id))
if (!enabledFilters.length && !filterOptions.length) {
throw new Error(
"DataTable.FilterMenu was rendered but there are no filters to apply. Make sure to pass filters to 'useDataTable'"
)
}
const Wrapper = props.tooltip ? Tooltip : React.Fragment
if (instance.showSkeleton) {
return <DataTableFilterMenuSkeleton />
}
return (
<DropdownMenu>
<Wrapper content={props.tooltip} hidden={filterOptions.length === 0}>
<DropdownMenu.Trigger asChild disabled={filterOptions.length === 0}>
<IconButton size="small">
<Funnel />
</IconButton>
</DropdownMenu.Trigger>
</Wrapper>
<DropdownMenu.Content side="bottom">
{filterOptions.map((filter) => (
<DropdownMenu.Item
key={filter.id}
onClick={() => {
instance.addFilter({ id: filter.id, value: undefined })
}}
>
{filter.label}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu>
)
}
const DataTableFilterMenuSkeleton = () => {
return <Skeleton className="size-7" />
}
export { DataTableFilterMenu }
export type { DataTableFilterMenuProps }

View File

@@ -0,0 +1,616 @@
"use client"
import { CheckMini, EllipseMiniSolid, XMark } from "@medusajs/icons"
import * as React from "react"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
import type {
DataTableDateComparisonOperator,
DataTableDateFilterProps,
DataTableFilterOption,
} from "@/blocks/data-table/types"
import { isDateComparisonOperator } from "@/blocks/data-table/utils/is-date-comparison-operator"
import { DatePicker } from "@/components/date-picker"
import { Label } from "@/components/label"
import { Popover } from "@/components/popover"
import { clx } from "@/utils/clx"
interface DataTableFilterProps {
id: string
filter: unknown
}
const DEFAULT_FORMAT_DATE_VALUE = (d: Date) =>
d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})
const DEFAULT_RANGE_OPTION_LABEL = "Custom"
const DEFAULT_RANGE_OPTION_START_LABEL = "Starting"
const DEFAULT_RANGE_OPTION_END_LABEL = "Ending"
const DataTableFilter = ({ id, filter }: DataTableFilterProps) => {
const { instance } = useDataTableContext()
const [open, setOpen] = React.useState(filter === undefined)
const [isCustom, setIsCustom] = React.useState(false)
const onOpenChange = React.useCallback(
(open: boolean) => {
if (
!open &&
(!filter || (Array.isArray(filter) && filter.length === 0))
) {
instance.removeFilter(id)
}
setOpen(open)
},
[instance, id, filter]
)
const removeFilter = React.useCallback(() => {
instance.removeFilter(id)
}, [instance, id])
const meta = instance.getFilterMeta(id)
const { type, options, label, ...rest } = meta ?? {}
const { displayValue, isCustomRange } = React.useMemo(() => {
let displayValue: string | null = null
let isCustomRange = false
if (typeof filter === "string") {
displayValue = options?.find((o) => o.value === filter)?.label ?? null
}
if (Array.isArray(filter)) {
displayValue =
filter
.map((v) => options?.find((o) => o.value === v)?.label)
.join(", ") ?? null
}
if (isDateComparisonOperator(filter)) {
displayValue =
options?.find((o) => {
if (!isDateComparisonOperator(o.value)) {
return false
}
return (
!isCustom &&
(filter.$gte === o.value.$gte || (!filter.$gte && !o.value.$gte)) &&
(filter.$lte === o.value.$lte || (!filter.$lte && !o.value.$lte)) &&
(filter.$gt === o.value.$gt || (!filter.$gt && !o.value.$gt)) &&
(filter.$lt === o.value.$lt || (!filter.$lt && !o.value.$lt))
)
})?.label ?? null
if (!displayValue && isDateFilterProps(meta)) {
const formatDateValue = meta.formatDateValue
? meta.formatDateValue
: DEFAULT_FORMAT_DATE_VALUE
if (filter.$gte && !filter.$lte) {
isCustomRange = true
displayValue = `${
meta.rangeOptionStartLabel || DEFAULT_RANGE_OPTION_START_LABEL
} ${formatDateValue(new Date(filter.$gte))}`
}
if (filter.$lte && !filter.$gte) {
isCustomRange = true
displayValue = `${
meta.rangeOptionEndLabel || DEFAULT_RANGE_OPTION_END_LABEL
} ${formatDateValue(new Date(filter.$lte))}`
}
if (filter.$gte && filter.$lte) {
isCustomRange = true
displayValue = `${formatDateValue(
new Date(filter.$gte)
)} - ${formatDateValue(new Date(filter.$lte))}`
}
}
}
return { displayValue, isCustomRange }
}, [filter, options])
React.useEffect(() => {
if (isCustomRange && !isCustom) {
setIsCustom(true)
}
}, [isCustomRange, isCustom])
if (!meta) {
return null
}
return (
<Popover open={open} onOpenChange={onOpenChange} modal>
<Popover.Anchor asChild>
<div
className={clx(
"bg-ui-bg-component flex flex-shrink-0 items-center overflow-hidden rounded-md",
"[&>*]:txt-compact-small-plus [&>*]:flex [&>*]:items-center [&>*]:justify-center",
{
"shadow-borders-base divide-x": displayValue,
"border border-dashed": !displayValue,
}
)}
>
{displayValue && (
<div className="text-ui-fg-muted whitespace-nowrap px-2 py-1">
{label || id}
</div>
)}
<Popover.Trigger
className={clx(
"text-ui-fg-subtle hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed transition-fg whitespace-nowrap px-2 py-1 outline-none",
{
"text-ui-fg-muted": !displayValue,
}
)}
>
{displayValue || label || id}
</Popover.Trigger>
{displayValue && (
<button
type="button"
className="text-ui-fg-muted hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed transition-fg size-7 outline-none"
onClick={removeFilter}
>
<XMark />
</button>
)}
</div>
</Popover.Anchor>
<Popover.Content
align="start"
className="bg-ui-bg-component p-0 outline-none"
>
{(() => {
switch (type) {
case "select":
return (
<DataTableFilterSelectContent
id={id}
filter={filter as string[] | undefined}
options={options as DataTableFilterOption<string>[]}
/>
)
case "radio":
return (
<DataTableFilterRadioContent
id={id}
filter={filter}
options={options as DataTableFilterOption<string>[]}
/>
)
case "date":
return (
<DataTableFilterDateContent
id={id}
filter={filter}
options={
options as DataTableFilterOption<DataTableDateComparisonOperator>[]
}
isCustom={isCustom}
setIsCustom={setIsCustom}
{...rest}
/>
)
default:
return null
}
})()}
</Popover.Content>
</Popover>
)
}
type DataTableFilterDateContentProps = {
id: string
filter: unknown
options: DataTableFilterOption<DataTableDateComparisonOperator>[]
isCustom: boolean
setIsCustom: (isCustom: boolean) => void
} & Pick<
DataTableDateFilterProps,
| "format"
| "rangeOptionLabel"
| "disableRangeOption"
| "rangeOptionStartLabel"
| "rangeOptionEndLabel"
>
const DataTableFilterDateContent = ({
id,
filter,
options,
format = "date",
rangeOptionLabel = DEFAULT_RANGE_OPTION_LABEL,
rangeOptionStartLabel = DEFAULT_RANGE_OPTION_START_LABEL,
rangeOptionEndLabel = DEFAULT_RANGE_OPTION_END_LABEL,
disableRangeOption = false,
isCustom,
setIsCustom,
}: DataTableFilterDateContentProps) => {
const currentValue = filter as DataTableDateComparisonOperator | undefined
const { instance } = useDataTableContext()
const selectedValue = React.useMemo(() => {
if (!currentValue || isCustom) {
return undefined
}
return JSON.stringify(currentValue)
}, [currentValue, isCustom])
const onValueChange = React.useCallback(
(valueStr: string) => {
setIsCustom(false)
const value = JSON.parse(valueStr) as DataTableDateComparisonOperator
instance.updateFilter({ id, value })
},
[instance, id]
)
const onSelectCustom = React.useCallback(() => {
setIsCustom(true)
instance.updateFilter({ id, value: undefined })
}, [instance, id])
const onCustomValueChange = React.useCallback(
(input: "$gte" | "$lte", value: Date | null) => {
const newCurrentValue = { ...currentValue }
newCurrentValue[input] = value ? value.toISOString() : undefined
instance.updateFilter({ id, value: newCurrentValue })
},
[instance, id]
)
const { focusedIndex, setFocusedIndex } = useKeyboardNavigation(
options,
(index) => {
if (index === options.length && !disableRangeOption) {
onSelectCustom()
} else {
onValueChange(JSON.stringify(options[index].value))
}
},
disableRangeOption ? 0 : 1
)
const granularity = format === "date-time" ? "minute" : "day"
const maxDate = currentValue?.$lte
? granularity === "minute"
? new Date(currentValue.$lte)
: new Date(new Date(currentValue.$lte).setHours(23, 59, 59, 999))
: undefined
const minDate = currentValue?.$gte
? granularity === "minute"
? new Date(currentValue.$gte)
: new Date(new Date(currentValue.$gte).setHours(0, 0, 0, 0))
: undefined
const initialFocusedIndex = isCustom ? options.length : 0
const onListFocus = React.useCallback(() => {
if (focusedIndex === -1) {
setFocusedIndex(initialFocusedIndex)
}
}, [focusedIndex, initialFocusedIndex])
return (
<React.Fragment>
<div
className="flex flex-col p-1 outline-none"
tabIndex={0}
role="list"
onFocus={onListFocus}
autoFocus
>
{options.map((option, idx) => {
const value = JSON.stringify(option.value)
const isSelected = selectedValue === value
return (
<OptionButton
key={idx}
index={idx}
option={option}
isSelected={isSelected}
isFocused={focusedIndex === idx}
onClick={() => onValueChange(value)}
onMouseEvent={setFocusedIndex}
icon={EllipseMiniSolid}
/>
)
})}
{!disableRangeOption && (
<OptionButton
index={options.length}
option={{
label: rangeOptionLabel,
value: "__custom",
}}
icon={EllipseMiniSolid}
isSelected={isCustom}
isFocused={focusedIndex === options.length}
onClick={onSelectCustom}
onMouseEvent={setFocusedIndex}
/>
)}
</div>
{!disableRangeOption && isCustom && (
<React.Fragment>
<div className="flex flex-col py-[3px]">
<div className="bg-ui-border-menu-top h-px w-full" />
<div className="bg-ui-border-menu-bot h-px w-full" />
</div>
<div className="flex flex-col gap-2 px-2 pb-3 pt-1">
<div className="flex flex-col gap-1">
<Label id="custom-start-date-label" size="xsmall" weight="plus">
{rangeOptionStartLabel}
</Label>
<DatePicker
aria-labelledby="custom-start-date-label"
granularity={granularity}
maxValue={maxDate}
value={currentValue?.$gte ? new Date(currentValue.$gte) : null}
onChange={(value) => onCustomValueChange("$gte", value)}
/>
</div>
<div className="flex flex-col gap-1">
<Label id="custom-end-date-label" size="xsmall" weight="plus">
{rangeOptionEndLabel}
</Label>
<DatePicker
aria-labelledby="custom-end-date-label"
granularity={granularity}
minValue={minDate}
value={currentValue?.$lte ? new Date(currentValue.$lte) : null}
onChange={(value) => onCustomValueChange("$lte", value)}
/>
</div>
</div>
</React.Fragment>
)}
</React.Fragment>
)
}
type DataTableFilterSelectContentProps = {
id: string
filter?: string[]
options: DataTableFilterOption<string>[]
}
const DataTableFilterSelectContent = ({
id,
filter = [],
options,
}: DataTableFilterSelectContentProps) => {
const { instance } = useDataTableContext()
const onValueChange = React.useCallback(
(value: string) => {
if (filter?.includes(value)) {
const newValues = filter?.filter((v) => v !== value)
instance.updateFilter({
id,
value: newValues,
})
} else {
instance.updateFilter({
id,
value: [...(filter ?? []), value],
})
}
},
[instance, id, filter]
)
const { focusedIndex, setFocusedIndex } = useKeyboardNavigation(
options,
(index) => onValueChange(options[index].value)
)
const onListFocus = React.useCallback(() => {
if (focusedIndex === -1) {
setFocusedIndex(0)
}
}, [focusedIndex])
return (
<div
className="flex flex-col p-1 outline-none"
role="list"
tabIndex={0}
onFocus={onListFocus}
autoFocus
>
{options.map((option, idx) => {
const isSelected = !!filter?.includes(option.value)
return (
<OptionButton
key={idx}
index={idx}
option={option}
isSelected={isSelected}
isFocused={focusedIndex === idx}
onClick={() => onValueChange(option.value)}
onMouseEvent={setFocusedIndex}
icon={CheckMini}
/>
)
})}
</div>
)
}
type DataTableFilterRadioContentProps = {
id: string
filter: unknown
options: DataTableFilterOption<string>[]
}
const DataTableFilterRadioContent = ({
id,
filter,
options,
}: DataTableFilterRadioContentProps) => {
const { instance } = useDataTableContext()
const onValueChange = React.useCallback(
(value: string) => {
instance.updateFilter({ id, value })
},
[instance, id]
)
const { focusedIndex, setFocusedIndex } = useKeyboardNavigation(
options,
(index) => onValueChange(options[index].value)
)
const onListFocus = React.useCallback(() => {
if (focusedIndex === -1) {
setFocusedIndex(0)
}
}, [focusedIndex])
return (
<div
className="flex flex-col p-1 outline-none"
role="list"
tabIndex={0}
onFocus={onListFocus}
autoFocus
>
{options.map((option, idx) => {
const isSelected = filter === option.value
return (
<OptionButton
key={idx}
index={idx}
option={option}
isSelected={isSelected}
isFocused={focusedIndex === idx}
onClick={() => onValueChange(option.value)}
onMouseEvent={setFocusedIndex}
icon={EllipseMiniSolid}
/>
)
})}
</div>
)
}
function isDateFilterProps(props?: unknown | null): props is DataTableDateFilterProps {
if (!props) {
return false
}
return (props as DataTableDateFilterProps).type === "date"
}
type OptionButtonProps = {
index: number
option: DataTableFilterOption<string | DataTableDateComparisonOperator>
isSelected: boolean
isFocused: boolean
onClick: () => void
onMouseEvent: (idx: number) => void
icon: React.ElementType
}
const OptionButton = React.memo(
({
index,
option,
isSelected,
isFocused,
onClick,
onMouseEvent,
icon: Icon,
}: OptionButtonProps) => (
<button
type="button"
role="listitem"
className={clx(
"bg-ui-bg-component txt-compact-small transition-fg flex items-center gap-2 rounded px-2 py-1 outline-none",
{ "bg-ui-bg-component-hover": isFocused }
)}
onClick={onClick}
onMouseEnter={() => onMouseEvent(index)}
onMouseLeave={() => onMouseEvent(-1)}
tabIndex={-1}
>
<div className="flex size-[15px] items-center justify-center">
{isSelected && <Icon />}
</div>
<span>{option.label}</span>
</button>
)
)
function useKeyboardNavigation(
options: unknown[],
onSelect: (index: number) => void,
extraItems: number = 0
) {
const [focusedIndex, setFocusedIndex] = React.useState(-1)
const onKeyDown = React.useCallback(
(e: KeyboardEvent) => {
const totalLength = options.length + extraItems
if ((document.activeElement as HTMLElement).contentEditable === "true") {
return
}
switch (e.key) {
case "ArrowDown":
e.preventDefault()
setFocusedIndex((prev) => (prev < totalLength - 1 ? prev + 1 : prev))
break
case "ArrowUp":
e.preventDefault()
setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev))
break
case " ":
case "Enter":
e.preventDefault()
if (focusedIndex >= 0) {
onSelect(focusedIndex)
}
break
}
},
[options.length, extraItems, focusedIndex, onSelect]
)
React.useEffect(() => {
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [onKeyDown])
return { focusedIndex, setFocusedIndex }
}
export { DataTableFilter }
export type { DataTableFilterProps }

View File

@@ -0,0 +1,59 @@
"use client"
import * as React from "react"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
import { Skeleton } from "@/components/skeleton"
import { Table } from "@/components/table"
interface DataTablePaginationProps {
translations?: React.ComponentProps<typeof Table.Pagination>["translations"]
}
const DataTablePagination = (props: DataTablePaginationProps) => {
const { instance } = useDataTableContext()
if (!instance.enablePagination) {
throw new Error(
"DataTable.Pagination was rendered but pagination is not enabled. Make sure to pass pagination to 'useDataTable'"
)
}
if (instance.showSkeleton) {
return <DataTablePaginationSkeleton />
}
return (
<Table.Pagination
translations={props.translations}
className="flex-shrink-0"
canNextPage={instance.getCanNextPage()}
canPreviousPage={instance.getCanPreviousPage()}
pageCount={instance.getPageCount()}
count={instance.rowCount}
nextPage={instance.nextPage}
previousPage={instance.previousPage}
pageIndex={instance.pageIndex}
pageSize={instance.pageSize}
/>
)
}
const DataTablePaginationSkeleton = () => {
return (
<div>
<div className="flex items-center justify-between p-4">
<Skeleton className="h-7 w-[138px]" />
<div className="flex items-center gap-x-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-11" />
<Skeleton className="h-7 w-11" />
</div>
</div>
</div>
)
}
export { DataTablePagination }
export type { DataTablePaginationProps }

View File

@@ -0,0 +1,53 @@
"use client"
import { Input } from "@/components/input"
import { Skeleton } from "@/components/skeleton"
import { clx } from "@/utils/clx"
import * as React from "react"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
interface DataTableSearchProps {
autoFocus?: boolean
className?: string
placeholder?: string
}
const DataTableSearch = (props: DataTableSearchProps) => {
const { className, ...rest } = props
const { instance } = useDataTableContext()
if (!instance.enableSearch) {
throw new Error(
"DataTable.Search was rendered but search is not enabled. Make sure to pass search to 'useDataTable'"
)
}
if (instance.showSkeleton) {
return <DataTableSearchSkeleton />
}
return (
<Input
size="small"
type="search"
value={instance.getSearch()}
onChange={(e) => instance.onSearchChange(e.target.value)}
className={clx(
{
"pr-[calc(15px+2px+8px)]": instance.isLoading,
},
className
)}
{...rest}
/>
)
}
const DataTableSearchSkeleton = () => {
return <Skeleton className="h-7 w-[128px]" />
}
export { DataTableSearch }
export type { DataTableSearchProps }

View File

@@ -0,0 +1,49 @@
"use client"
import type { DataTableCellContext, DataTableHeaderContext } from "@/blocks/data-table/types"
import { Checkbox } from "@/components/checkbox"
import { CheckedState } from "@radix-ui/react-checkbox"
import * as React from "react"
interface DataTableSelectCellProps<TData> {
ctx: DataTableCellContext<TData, unknown>
}
const DataTableSelectCell = <TData,>(props: DataTableSelectCellProps<TData>) => {
const checked = props.ctx.row.getIsSelected()
const onChange = props.ctx.row.getToggleSelectedHandler()
return (
<Checkbox
onClick={(e) => e.stopPropagation()}
checked={checked}
onCheckedChange={onChange}
/>
)
}
interface DataTableSelectHeaderProps<TData> {
ctx: DataTableHeaderContext<TData, unknown>
}
const DataTableSelectHeader = <TData,>(props: DataTableSelectHeaderProps<TData>) => {
const checked = props.ctx.table.getIsSomePageRowsSelected()
? "indeterminate"
: props.ctx.table.getIsAllPageRowsSelected()
const onChange = (checked: CheckedState) => {
props.ctx.table.toggleAllPageRowsSelected(!!checked)
}
return (
<Checkbox
onClick={(e) => e.stopPropagation()}
checked={checked}
onCheckedChange={onChange}
/>
)
}
export { DataTableSelectCell, DataTableSelectHeader }
export type { DataTableSelectCellProps, DataTableSelectHeaderProps }

View File

@@ -0,0 +1,46 @@
"use client"
import { DataTableSortDirection } from "@/blocks/data-table/types"
import { clx } from "@/utils/clx"
import * as React from "react"
interface SortingIconProps {
direction: DataTableSortDirection | false
}
const DataTableSortingIcon = (props: SortingIconProps) => {
const isAscending = props.direction === "asc"
const isDescending = props.direction === "desc"
const isSorted = isAscending || isDescending
return (
<svg
width="16"
height="15"
viewBox="0 0 16 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={clx("opacity-0 transition-opacity group-hover:opacity-100", {
"opacity-100": isSorted,
})}
>
<path
d="M5.82651 5.75C5.66344 5.74994 5.50339 5.71269 5.36308 5.64216C5.22277 5.57162 5.10736 5.47039 5.02891 5.34904C4.95045 5.22769 4.91184 5.09067 4.9171 4.95232C4.92236 4.81397 4.97131 4.67936 5.05882 4.56255L7.64833 1.10788C7.73055 0.998207 7.84403 0.907911 7.97827 0.845354C8.11252 0.782797 8.26318 0.75 8.41632 0.75C8.56946 0.75 8.72013 0.782797 8.85437 0.845354C8.98862 0.907911 9.1021 0.998207 9.18432 1.10788L11.7744 4.56255C11.862 4.67939 11.9109 4.81405 11.9162 4.95245C11.9214 5.09085 11.8827 5.2279 11.8042 5.34926C11.7257 5.47063 11.6102 5.57185 11.4698 5.64235C11.3294 5.71285 11.1693 5.75003 11.0061 5.75H5.82651Z"
className={clx("fill-ui-fg-muted", {
"fill-ui-fg-subtle": isAscending,
})}
/>
<path
d="M11.0067 9.25C11.1698 9.25006 11.3299 9.28731 11.4702 9.35784C11.6105 9.42838 11.7259 9.52961 11.8043 9.65096C11.8828 9.77231 11.9214 9.90933 11.9162 10.0477C11.9109 10.186 11.8619 10.3206 11.7744 10.4374L9.18492 13.8921C9.10271 14.0018 8.98922 14.0921 8.85498 14.1546C8.72074 14.2172 8.57007 14.25 8.41693 14.25C8.26379 14.25 8.11312 14.2172 7.97888 14.1546C7.84464 14.0921 7.73115 14.0018 7.64894 13.8921L5.05882 10.4374C4.97128 10.3206 4.92233 10.1859 4.9171 10.0476C4.91186 9.90915 4.95053 9.7721 5.02905 9.65074C5.10758 9.52937 5.22308 9.42815 5.36347 9.35765C5.50387 9.28715 5.664 9.24997 5.82712 9.25H11.0067Z"
className={clx("fill-ui-fg-muted", {
"fill-ui-fg-subtle": isDescending,
})}
/>
</svg>
)
}
export { DataTableSortingIcon }
export type { SortingIconProps }

View File

@@ -0,0 +1,153 @@
"use client"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
import { DataTableColumn, DataTableSortableColumnDefMeta } from "@/blocks/data-table/types"
import { DropdownMenu } from "@/components/dropdown-menu"
import { IconButton } from "@/components/icon-button"
import { Skeleton } from "@/components/skeleton"
import { Tooltip } from "@/components/tooltip"
import { ArrowDownMini, ArrowUpMini, DescendingSorting } from "@medusajs/icons"
import * as React from "react"
interface DataTableSortingMenuProps {
tooltip?: string
}
const DataTableSortingMenu = (props: DataTableSortingMenuProps) => {
const { instance } = useDataTableContext()
const sortableColumns = instance
.getAllColumns()
.filter((column) => column.getCanSort())
const sorting = instance.getSorting()
const selectedColumn = React.useMemo(() => {
return sortableColumns.find((column) => column.id === sorting?.id)
}, [sortableColumns, sorting])
const setKey = React.useCallback(
(key: string) => {
instance.setSorting((prev) => ({ id: key, desc: prev?.desc ?? false }))
},
[instance]
)
const setDesc = React.useCallback(
(desc: string) => {
instance.setSorting((prev) => ({
id: prev?.id ?? "",
desc: desc === "true",
}))
},
[instance]
)
if (!instance.enableSorting) {
throw new Error(
"DataTable.SortingMenu was rendered but sorting is not enabled. Make sure to pass sorting to 'useDataTable'"
)
}
if (!sortableColumns.length) {
throw new Error(
"DataTable.SortingMenu was rendered but there are no sortable columns. Make sure to set `enableSorting` to true on at least one column."
)
}
if (instance.showSkeleton) {
return <DataTableSortingMenuSkeleton />
}
const Wrapper = props.tooltip ? Tooltip : React.Fragment
return (
<DropdownMenu>
<Wrapper content={props.tooltip}>
<DropdownMenu.Trigger asChild>
<IconButton size="small">
<DescendingSorting />
</IconButton>
</DropdownMenu.Trigger>
</Wrapper>
<DropdownMenu.Content side="bottom">
<DropdownMenu.RadioGroup value={sorting?.id} onValueChange={setKey}>
{sortableColumns.map((column) => {
return (
<DropdownMenu.RadioItem
onSelect={(e) => e.preventDefault()}
value={column.id}
key={column.id}
>
{getSortLabel(column)}
</DropdownMenu.RadioItem>
)
})}
</DropdownMenu.RadioGroup>
{sorting && (
<React.Fragment>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup
value={sorting?.desc ? "true" : "false"}
onValueChange={setDesc}
>
<DropdownMenu.RadioItem
onSelect={(e) => e.preventDefault()}
value="false"
className="flex items-center gap-2"
>
<ArrowUpMini className="text-ui-fg-subtle" />
{getSortDescriptor("asc", selectedColumn)}
</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem
onSelect={(e) => e.preventDefault()}
value="true"
className="flex items-center gap-2"
>
<ArrowDownMini className="text-ui-fg-subtle" />
{getSortDescriptor("desc", selectedColumn)}
</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</React.Fragment>
)}
</DropdownMenu.Content>
</DropdownMenu>
)
}
function getSortLabel(column: DataTableColumn<any, unknown>) {
const meta = column.columnDef.meta as DataTableSortableColumnDefMeta | undefined
let headerValue: string | undefined = undefined
if (typeof column.columnDef.header === "string") {
headerValue = column.columnDef.header
}
return meta?.___sortMetaData?.sortLabel ?? headerValue ?? column.id
}
function getSortDescriptor(
direction: "asc" | "desc",
column?: DataTableColumn<any, unknown>
) {
if (!column) {
return null
}
const meta = column.columnDef.meta as DataTableSortableColumnDefMeta | undefined
switch (direction) {
case "asc":
return meta?.___sortMetaData?.sortAscLabel ?? "A-Z"
case "desc":
return meta?.___sortMetaData?.sortDescLabel ?? "Z-A"
}
}
const DataTableSortingMenuSkeleton = () => {
return <Skeleton className="size-7" />
}
export { DataTableSortingMenu }
export type { DataTableSortingMenuProps }

View File

@@ -0,0 +1,331 @@
"use client"
import * as React from "react"
import { Table } from "@/components/table"
import { flexRender } from "@tanstack/react-table"
import { useDataTableContext } from "@/blocks/data-table/context/use-data-table-context"
import { Skeleton } from "@/components/skeleton"
import { Text } from "@/components/text"
import { clx } from "@/utils/clx"
import {
DataTableEmptyState,
DataTableEmptyStateContent,
DataTableEmptyStateProps,
} from "../types"
import { DataTableSortingIcon } from "./data-table-sorting-icon"
interface DataTableTableProps {
/**
* The empty state to display when the table is empty.
*/
emptyState?: DataTableEmptyStateProps
}
const DataTableTable = (props: DataTableTableProps) => {
const [hoveredRowId, setHoveredRowId] = React.useState<string | null>(null)
const isKeyDown = React.useRef(false)
const [showStickyBorder, setShowStickyBorder] = React.useState(false)
const scrollableRef = React.useRef<HTMLDivElement>(null)
const { instance } = useDataTableContext()
const pageIndex = instance.pageIndex
const columns = instance.getAllColumns()
const hasSelect = columns.find((c) => c.id === "select")
const hasActions = columns.find((c) => c.id === "action")
React.useEffect(() => {
const onKeyDownHandler = (event: KeyboardEvent) => {
// If an editable element is focused, we don't want to select a row
const isEditableElementFocused = getIsEditableElementFocused()
if (
event.key.toLowerCase() === "x" &&
hoveredRowId &&
!isKeyDown.current &&
!isEditableElementFocused
) {
isKeyDown.current = true
const row = instance
.getRowModel()
.rows.find((r) => r.id === hoveredRowId)
if (row && row.getCanSelect()) {
row.toggleSelected()
}
}
}
const onKeyUpHandler = (event: KeyboardEvent) => {
if (event.key.toLowerCase() === "x") {
isKeyDown.current = false
}
}
document.addEventListener("keydown", onKeyDownHandler)
document.addEventListener("keyup", onKeyUpHandler)
return () => {
document.removeEventListener("keydown", onKeyDownHandler)
document.removeEventListener("keyup", onKeyUpHandler)
}
}, [hoveredRowId, instance])
const handleHorizontalScroll = (e: React.UIEvent<HTMLDivElement>) => {
const scrollLeft = e.currentTarget.scrollLeft
if (scrollLeft > 0) {
setShowStickyBorder(true)
} else {
setShowStickyBorder(false)
}
}
React.useEffect(() => {
scrollableRef.current?.scroll({ top: 0, left: 0 })
}, [pageIndex])
if (instance.showSkeleton) {
return <DataTableTableSkeleton pageSize={instance.pageSize} />
}
return (
<div className="flex w-full flex-1 flex-col overflow-hidden">
{instance.emptyState === DataTableEmptyState.POPULATED && (
<div
ref={scrollableRef}
onScroll={handleHorizontalScroll}
className="min-h-0 w-full flex-1 overflow-auto overscroll-none border-y"
>
<Table className="relative isolate w-full">
<Table.Header
className="shadow-ui-border-base sticky inset-x-0 top-0 z-[1] w-full border-b-0 border-t-0 shadow-[0_1px_1px_0]"
style={{ transform: "translate3d(0,0,0)" }}
>
{instance.getHeaderGroups().map((headerGroup) => (
<Table.Row
key={headerGroup.id}
className={clx("border-b-0", {
"[&_th:last-of-type]:w-[1%] [&_th:last-of-type]:whitespace-nowrap":
hasActions,
"[&_th:first-of-type]:w-[1%] [&_th:first-of-type]:whitespace-nowrap":
hasSelect,
})}
>
{headerGroup.headers.map((header, idx) => {
const canSort = header.column.getCanSort()
const sortDirection = header.column.getIsSorted()
const sortHandler = header.column.getToggleSortingHandler()
const isActionHeader = header.id === "action"
const isSelectHeader = header.id === "select"
const isSpecialHeader = isActionHeader || isSelectHeader
const Wrapper = canSort ? "button" : "div"
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
return (
<Table.HeaderCell
key={header.id}
className={clx("whitespace-nowrap", {
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
isSelectHeader,
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
isActionHeader,
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isFirstColumn,
"after:bg-ui-border-base":
showStickyBorder && isFirstColumn,
"bg-ui-bg-subtle sticky":
isFirstColumn || isSelectHeader,
"left-0":
isSelectHeader || (isFirstColumn && !hasSelect),
"left-[calc(20px+24px+24px)]":
isFirstColumn && hasSelect,
})}
style={
!isSpecialHeader
? {
width: header.column.columnDef.size,
maxWidth: header.column.columnDef.maxSize,
minWidth: header.column.columnDef.minSize,
}
: undefined
}
>
<Wrapper
type={canSort ? "button" : undefined}
onClick={canSort ? sortHandler : undefined}
className={clx(
"group flex w-fit cursor-default items-center gap-2",
{
"cursor-pointer": canSort,
}
)}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{canSort && (
<DataTableSortingIcon direction={sortDirection} />
)}
</Wrapper>
</Table.HeaderCell>
)
})}
</Table.Row>
))}
</Table.Header>
<Table.Body className="border-b-0 border-t-0">
{instance.getRowModel().rows.map((row) => {
return (
<Table.Row
key={row.id}
onMouseEnter={() => setHoveredRowId(row.id)}
onMouseLeave={() => setHoveredRowId(null)}
onClick={(e) => instance.onRowClick?.(e, row)}
className={clx("group/row last:border-b-0", {
"cursor-pointer": !!instance.onRowClick,
})}
>
{row.getVisibleCells().map((cell, idx) => {
const isSelectCell = cell.column.id === "select"
const isActionCell = cell.column.id === "action"
const isSpecialCell = isSelectCell || isActionCell
const isFirstColumn = hasSelect ? idx === 1 : idx === 0
return (
<Table.Cell
key={cell.id}
className={clx(
"items-stretch truncate whitespace-nowrap",
{
"w-[calc(20px+24px+24px)] min-w-[calc(20px+24px+24px)] max-w-[calc(20px+24px+24px)]":
isSelectCell,
"w-[calc(28px+24px+4px)] min-w-[calc(28px+24px+4px)] max-w-[calc(28px+24px+4px)]":
isActionCell,
"bg-ui-bg-base group-hover/row:bg-ui-bg-base-hover transition-fg sticky h-full":
isFirstColumn || isSelectCell,
"after:absolute after:inset-y-0 after:right-0 after:h-full after:w-px after:bg-transparent after:content-['']":
isFirstColumn,
"after:bg-ui-border-base":
showStickyBorder && isFirstColumn,
"left-0":
isSelectCell || (isFirstColumn && !hasSelect),
"left-[calc(20px+24px+24px)]":
isFirstColumn && hasSelect,
}
)}
style={
!isSpecialCell
? {
width: cell.column.columnDef.size,
maxWidth: cell.column.columnDef.maxSize,
minWidth: cell.column.columnDef.minSize,
}
: undefined
}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</Table.Cell>
)
})}
</Table.Row>
)
})}
</Table.Body>
</Table>
</div>
)}
<DataTableEmptyStateDisplay
state={instance.emptyState}
props={props.emptyState}
/>
</div>
)
}
interface DataTableEmptyStateDisplayProps {
state: DataTableEmptyState
props?: DataTableEmptyStateProps
}
const DefaultEmptyStateContent = ({
heading,
description,
}: DataTableEmptyStateContent) => (
<div className="flex size-full flex-col items-center justify-center gap-2">
<Text size="base" weight="plus">
{heading}
</Text>
<Text>{description}</Text>
</div>
)
const DataTableEmptyStateDisplay = ({
state,
props,
}: DataTableEmptyStateDisplayProps) => {
if (state === DataTableEmptyState.POPULATED) {
return null
}
const content =
state === DataTableEmptyState.EMPTY ? props?.empty : props?.filtered
return (
<div className="flex min-h-[250px] w-full flex-col items-center justify-center border-y px-6 py-4">
{content?.custom ?? (
<DefaultEmptyStateContent
heading={content?.heading}
description={content?.description}
/>
)}
</div>
)
}
interface DataTableTableSkeletonProps {
pageSize?: number
}
const DataTableTableSkeleton = ({
pageSize = 10,
}: DataTableTableSkeletonProps) => {
return (
<div className="flex w-full flex-1 flex-col overflow-hidden">
<div className="min-h-0 w-full flex-1 overscroll-none border-y">
<div className="flex flex-col divide-y">
<Skeleton className="h-12 w-full" />
{Array.from({ length: pageSize }, (_, i) => i).map((row) => (
<Skeleton key={row} className="h-12 w-full rounded-none" />
))}
</div>
</div>
</div>
)
}
function getIsEditableElementFocused() {
const activeElement = !!document ? document.activeElement : null
const isEditableElementFocused =
activeElement instanceof HTMLInputElement ||
activeElement instanceof HTMLTextAreaElement ||
activeElement?.getAttribute("contenteditable") === "true"
return isEditableElementFocused
}
export { DataTableTable }
export type { DataTableEmptyStateProps, DataTableTableProps }

View File

@@ -0,0 +1,34 @@
import { DataTableFilterBar } from "@/blocks/data-table/components/data-table-filter-bar"
import { clx } from "@/utils/clx"
import * as React from "react"
interface DataTableToolbarTranslations {
/**
* The label for the clear all filters button
* @default "Clear all"
*/
clearAll?: string
}
interface DataTableToolbarProps {
className?: string
children?: React.ReactNode
translations?: DataTableToolbarTranslations
}
const DataTableToolbar = (props: DataTableToolbarProps) => {
return (
<div className="flex flex-col divide-y">
<div className={clx("flex items-center px-6 py-4", props.className)}>
{props.children}
</div>
<DataTableFilterBar
clearAllFiltersLabel={props.translations?.clearAll}
/>
</div>
)
}
export { DataTableToolbar }
export type { DataTableToolbarProps, DataTableToolbarTranslations }

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { UseDataTableReturn } from "../use-data-table"
import { DataTableContext } from "./data-table-context"
type DataTableContextProviderProps<TData> = {
instance: UseDataTableReturn<TData>
children: React.ReactNode
}
const DataTableContextProvider = <TData,>({
instance,
children,
}: DataTableContextProviderProps<TData>) => {
return (
<DataTableContext.Provider value={{ instance }}>
{children}
</DataTableContext.Provider>
)
}
export { DataTableContextProvider }

View File

@@ -0,0 +1,9 @@
import { createContext } from "react"
import { UseDataTableReturn } from "../use-data-table"
export interface DataTableContextValue<TData> {
instance: UseDataTableReturn<TData>
}
export const DataTableContext =
createContext<DataTableContextValue<any> | null>(null)

View File

@@ -0,0 +1,17 @@
import * as React from "react"
import { DataTableContext, DataTableContextValue } from "./data-table-context"
const useDataTableContext = <TData,>(): DataTableContextValue<TData> => {
const context = React.useContext(DataTableContext)
if (!context) {
throw new Error(
"useDataTableContext must be used within a DataTableContextProvider"
)
}
return context
}
export { useDataTableContext }

View File

@@ -0,0 +1,473 @@
import { faker } from "@faker-js/faker"
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Container } from "@/components/container"
import { PencilSquare, Trash } from "@medusajs/icons"
import { Button } from "../../components/button"
import { Heading } from "../../components/heading"
import { TooltipProvider } from "../../components/tooltip"
import { DataTable } from "./data-table"
import {
DataTableFilteringState,
DataTablePaginationState,
DataTableRowSelectionState,
DataTableSortingState,
} from "./types"
import { useDataTable } from "./use-data-table"
import { createDataTableColumnHelper } from "./utils/create-data-table-column-helper"
import { createDataTableCommandHelper } from "./utils/create-data-table-command-helper"
import { createDataTableFilterHelper } from "./utils/create-data-table-filter-helper"
import { isDateComparisonOperator } from "./utils/is-date-comparison-operator"
const meta: Meta<typeof DataTable> = {
title: "Blocks/DataTable",
component: DataTable,
}
export default meta
type Story = StoryObj<typeof DataTable>
type Employee = {
id: string
name: string
email: string
position: string
age: number
birthday: Date
relationshipStatus: "single" | "married" | "divorced" | "widowed"
}
const generateEmployees = (count: number): Employee[] => {
return Array.from({ length: count }, (_, i) => {
const age = faker.number.int({ min: 18, max: 65 })
const birthday = faker.date.birthdate({
mode: "age",
min: age,
max: age,
})
return {
id: i.toString(),
name: faker.person.fullName(),
email: faker.internet.email(),
position: faker.person.jobTitle(),
age,
birthday,
relationshipStatus: faker.helpers.arrayElement([
"single",
"married",
"divorced",
"widowed",
]),
}
})
}
const data: Employee[] = generateEmployees(100)
const usePeople = ({
q,
order,
filters,
offset,
limit,
}: {
q?: string
order?: DataTableSortingState | null
filters?: DataTableFilteringState
offset?: number
limit?: number
}) => {
return React.useMemo(() => {
let results = [...data]
if (q) {
results = results.filter((person) =>
person.name.toLowerCase().includes(q.toLowerCase())
)
}
if (filters && Object.keys(filters).length > 0) {
results = results.filter((person) => {
return Object.entries(filters).every(([key, filter]) => {
if (!filter) return true
const value = person[key as keyof Employee]
if (key === "birthday") {
if (isDateComparisonOperator(filter)) {
if (!(value instanceof Date)) {
return false
}
if (filter.$gte) {
const compareDate = new Date(filter.$gte)
if (value < compareDate) {
return false
}
}
if (filter.$lte) {
const compareDate = new Date(filter.$lte)
if (value > compareDate) {
return false
}
}
if (filter.$gt) {
const compareDate = new Date(filter.$gt)
if (value <= compareDate) {
return false
}
}
if (filter.$lt) {
const compareDate = new Date(filter.$lt)
if (value >= compareDate) {
return false
}
}
return true
}
}
if (Array.isArray(filter)) {
if (filter.length === 0) return true
return filter.includes(value)
}
return filter === value
})
})
}
// Apply sorting
if (order) {
const key = order.id as keyof Employee
const desc = order.desc
results.sort((a, b) => {
const aVal = a[key]
const bVal = b[key]
if (aVal instanceof Date && bVal instanceof Date) {
return desc
? bVal.getTime() - aVal.getTime()
: aVal.getTime() - bVal.getTime()
}
if (aVal < bVal) return desc ? 1 : -1
if (aVal > bVal) return desc ? -1 : 1
return 0
})
}
if (offset) {
results = results.slice(offset)
}
if (limit) {
results = results.slice(0, limit)
}
return {
data: results,
count: data.length,
}
}, [q, order, filters, offset, limit]) // Add filters to dependencies
}
const columnHelper = createDataTableColumnHelper<Employee>()
const columns = [
columnHelper.select(),
columnHelper.accessor("name", {
header: "Name",
enableSorting: true,
sortAscLabel: "A-Z",
sortDescLabel: "Z-A",
}),
columnHelper.accessor("email", {
header: "Email",
enableSorting: true,
sortAscLabel: "A-Z",
sortDescLabel: "Z-A",
maxSize: 200,
}),
columnHelper.accessor("position", {
header: "Position",
enableSorting: true,
sortAscLabel: "A-Z",
sortDescLabel: "Z-A",
}),
columnHelper.accessor("age", {
header: "Age",
enableSorting: true,
sortAscLabel: "Low to High",
sortDescLabel: "High to Low",
sortLabel: "Age",
}),
columnHelper.accessor("birthday", {
header: "Birthday",
cell: ({ row }) => {
return (
<div>
{row.original.birthday.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
)
},
enableSorting: true,
sortAscLabel: "Oldest to Youngest",
sortDescLabel: "Youngest to Oldest",
}),
columnHelper.accessor("relationshipStatus", {
header: "Relationship Status",
cell: ({ row }) => {
return (
<div>
{row.original.relationshipStatus.charAt(0).toUpperCase() +
row.original.relationshipStatus.slice(1)}
</div>
)
},
enableSorting: true,
sortAscLabel: "A-Z",
sortDescLabel: "Z-A",
}),
columnHelper.action({
actions: (ctx) => {
const actions = [
[
{
label: "Edit",
onClick: () => {},
icon: <PencilSquare />,
},
{
label: "Edit",
onClick: () => {},
icon: <PencilSquare />,
},
{
label: "Edit",
onClick: () => {},
icon: <PencilSquare />,
},
],
[
{
label: "Delete",
onClick: () => {},
icon: <Trash />,
},
],
]
return actions
},
}),
]
const filterHelper = createDataTableFilterHelper<Employee>()
const filters = [
filterHelper.accessor("birthday", {
label: "Birthday",
type: "date",
format: "date",
options: [
{
label: "18 - 25 years old",
value: {
$lte: new Date(
new Date().setFullYear(new Date().getFullYear() - 18)
).toISOString(),
$gte: new Date(
new Date().setFullYear(new Date().getFullYear() - 25)
).toISOString(),
},
},
{
label: "26 - 35 years old",
value: {
$lte: new Date(
new Date().setFullYear(new Date().getFullYear() - 26)
).toISOString(),
$gte: new Date(
new Date().setFullYear(new Date().getFullYear() - 35)
).toISOString(),
},
},
{
label: "36 - 45 years old",
value: {
$lte: new Date(
new Date().setFullYear(new Date().getFullYear() - 36)
).toISOString(),
$gte: new Date(
new Date().setFullYear(new Date().getFullYear() - 45)
).toISOString(),
},
},
{
label: "46 - 55 years old",
value: {
$lte: new Date(
new Date().setFullYear(new Date().getFullYear() - 46)
).toISOString(),
$gte: new Date(
new Date().setFullYear(new Date().getFullYear() - 55)
).toISOString(),
},
},
{
label: "Over 55 years old",
value: {
$lt: new Date(
new Date().setFullYear(new Date().getFullYear() - 55)
).toISOString(),
},
},
],
}),
filterHelper.accessor("relationshipStatus", {
label: "Relationship Status",
type: "select",
options: [
{ label: "Single", value: "single" },
{ label: "Married", value: "married" },
{ label: "Divorced", value: "divorced" },
{ label: "Widowed", value: "widowed" },
],
}),
]
const commandHelper = createDataTableCommandHelper()
const commands = [
commandHelper.command({
label: "Archive",
action: (selection) => {
alert(`Archive ${Object.keys(selection).length} items`)
},
shortcut: "A",
}),
commandHelper.command({
label: "Delete",
action: (selection) => {
alert(`Delete ${Object.keys(selection).length} items`)
},
shortcut: "D",
}),
]
const KitchenSinkDemo = () => {
const [search, setSearch] = React.useState("")
const [rowSelection, setRowSelection] =
React.useState<DataTableRowSelectionState>({})
const [sorting, setSorting] = React.useState<DataTableSortingState | null>(
null
)
const [filtering, setFiltering] = React.useState<DataTableFilteringState>({
birthday: {
$gte: new Date(
new Date().setFullYear(new Date().getFullYear() - 18)
).toISOString(),
},
})
const [pagination, setPagination] = React.useState<DataTablePaginationState>({
pageIndex: 0,
pageSize: 10,
})
const { data, count } = usePeople({
q: search,
order: sorting,
filters: filtering,
offset: pagination.pageIndex * pagination.pageSize,
limit: pagination.pageSize,
})
const table = useDataTable({
data,
columns,
filters,
commands,
rowCount: count,
getRowId: (row) => row.id,
onRowClick: (_event, row) => {
alert(`Navigate to ${row.id}`)
},
search: {
state: search,
onSearchChange: setSearch,
},
filtering: {
state: filtering,
onFilteringChange: setFiltering,
},
rowSelection: {
state: rowSelection,
onRowSelectionChange: setRowSelection,
},
sorting: {
state: sorting,
onSortingChange: setSorting,
},
pagination: {
state: pagination,
onPaginationChange: setPagination,
},
})
return (
<TooltipProvider>
<Container className="flex flex-col overflow-hidden p-0">
<DataTable instance={table}>
<DataTable.Toolbar className="flex flex-col items-start justify-between gap-2 md:flex-row md:items-center">
<Heading>Employees</Heading>
<div className="flex w-full items-center gap-2 md:w-auto">
<DataTable.Search placeholder="Search" autoFocus />
<DataTable.FilterMenu tooltip="Filter" />
<DataTable.SortingMenu tooltip="Sort" />
<Button size="small" variant="secondary">
Create
</Button>
</div>
</DataTable.Toolbar>
<DataTable.Table
emptyState={{
empty: {
heading: "No employees",
description: "There are no employees to display.",
},
filtered: {
heading: "No results",
description:
"No employees match the current filter criteria. Try adjusting your filters.",
},
}}
/>
<DataTable.Pagination />
<DataTable.CommandBar
selectedLabel={(count) => `${count} selected`}
/>
</DataTable>
</Container>
</TooltipProvider>
)
}
export const KitchenSink: Story = {
render: () => <KitchenSinkDemo />,
}

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import { clx } from "@/utils/clx"
import { DataTableCommandBar } from "./components/data-table-command-bar"
import { DataTableFilterMenu } from "./components/data-table-filter-menu"
import { DataTablePagination } from "./components/data-table-pagination"
import { DataTableSearch } from "./components/data-table-search"
import { DataTableSortingMenu } from "./components/data-table-sorting-menu"
import { DataTableTable } from "./components/data-table-table"
import { DataTableToolbar } from "./components/data-table-toolbar"
import { DataTableContextProvider } from "./context/data-table-context-provider"
import { UseDataTableReturn } from "./use-data-table"
interface DataTableProps<TData> {
instance: UseDataTableReturn<TData>
children?: React.ReactNode
className?: string
}
const Root = <TData,>({
instance,
children,
className,
}: DataTableProps<TData>) => {
return (
<DataTableContextProvider instance={instance}>
<div className={clx("relative flex min-h-0 flex-1 flex-col", className)}>
{children}
</div>
</DataTableContextProvider>
)
}
const DataTable = Object.assign(Root, {
Table: DataTableTable,
Toolbar: DataTableToolbar,
Search: DataTableSearch,
SortingMenu: DataTableSortingMenu,
FilterMenu: DataTableFilterMenu,
Pagination: DataTablePagination,
CommandBar: DataTableCommandBar,
})
export { DataTable }
export type { DataTableProps }

View File

@@ -0,0 +1,25 @@
export * from "./data-table"
export * from "./use-data-table"
export * from "./utils/create-data-table-column-helper"
export * from "./utils/create-data-table-command-helper"
export * from "./utils/create-data-table-filter-helper"
export type {
DataTableAction,
DataTableCellContext,
DataTableColumn,
DataTableColumnDef,
DataTableColumnFilter,
DataTableCommand,
DataTableDateComparisonOperator,
DataTableEmptyState,
DataTableEmptyStateContent,
DataTableEmptyStateProps,
DataTableFilter,
DataTableFilteringState,
DataTableHeaderContext,
DataTablePaginationState,
DataTableRowSelectionState,
DataTableSortDirection,
DataTableSortingState,
} from "./types"

View File

@@ -0,0 +1,342 @@
import type {
AccessorFn,
AccessorFnColumnDef,
AccessorKeyColumnDef,
CellContext,
Column,
ColumnDef,
ColumnFilter,
ColumnSort,
DeepKeys,
DeepValue,
DisplayColumnDef,
HeaderContext,
IdentifiedColumnDef,
IdIdentifier,
PaginationState,
RowData,
RowSelectionState,
SortDirection,
StringHeaderIdentifier,
StringOrTemplateHeader,
} from "@tanstack/react-table"
export type DataTableRowData = RowData
export type DataTableAction<TData> = {
label: string
onClick: (ctx: CellContext<TData, unknown>) => void
icon?: React.ReactNode
}
export interface DataTableCellContext<TData extends DataTableRowData, TValue>
extends CellContext<TData, TValue> {}
export interface DataTableHeaderContext<TData extends DataTableRowData, TValue>
extends HeaderContext<TData, TValue> {}
export type DataTableSortDirection = SortDirection
export interface DataTableActionColumnDef<TData>
extends Pick<DisplayColumnDef<TData>, "meta"> {
actions:
| DataTableAction<TData>[]
| DataTableAction<TData>[][]
| ((
ctx: DataTableCellContext<TData, unknown>
) => DataTableAction<TData>[] | DataTableAction<TData>[][])
}
export interface DataTableSelectColumnDef<TData>
extends Pick<DisplayColumnDef<TData>, "cell" | "header"> {}
export type DataTableSortableColumnDef = {
/**
* The label to display in the sorting menu.
*/
sortLabel?: string
/**
* The label to display in the sorting menu when sorting in ascending order.
*/
sortAscLabel?: string
/**
* The label to display in the sorting menu when sorting in descending order.
*/
sortDescLabel?: string
/**
* Whether the column is sortable.
* @default false
*/
enableSorting?: boolean
}
export type DataTableSortableColumnDefMeta = {
___sortMetaData?: DataTableSortableColumnDef
}
export type DataTableActionColumnDefMeta<TData> = {
___actions?:
| DataTableAction<TData>[]
| DataTableAction<TData>[][]
| ((ctx: DataTableCellContext<TData, unknown>) => DataTableAction<TData>[])
}
export interface DataTableColumn<
TData extends DataTableRowData,
TValue = unknown
> extends Column<TData, TValue> {}
export type DataTableColumnDef<
TData extends DataTableRowData,
TValue = unknown
> = ColumnDef<TData, TValue>
export type DataTableColumnSizing = {
/**
* The maximum size of the column.
*/
maxSize?: number
/**
* The minimum size of the column.
*/
minSize?: number
/**
* The size of the column.
*/
size?: number
}
type DataTableColumnIdentifiers<TData extends DataTableRowData, TValue> =
| IdIdentifier<TData, TValue>
| StringHeaderIdentifier
export type DataTableDisplayColumnDef<
TData extends DataTableRowData,
TValue = unknown
> = Pick<
DisplayColumnDef<TData, TValue>,
"meta" | "header" | "cell" | "minSize" | "maxSize" | "size"
> &
DataTableColumnIdentifiers<TData, TValue>
export interface DataTableIdentifiedColumnDef<
TData extends DataTableRowData,
TValue
> extends Pick<
IdentifiedColumnDef<TData, TValue>,
"meta" | "header" | "cell" | "minSize" | "maxSize" | "size"
> {
id?: string
header?: StringOrTemplateHeader<TData, TValue>
}
export interface DataTableColumnHelper<TData> {
/**
* Create a accessor column.
*
* @param accessor The accessor to create the column for.
* @param column The column to create for the accessor.
* @returns The created accessor.
*/
accessor: <
TAccessor extends AccessorFn<TData> | DeepKeys<TData>,
TValue extends TAccessor extends AccessorFn<TData, infer TReturn>
? TReturn
: TAccessor extends DeepKeys<TData>
? DeepValue<TData, TAccessor>
: never
>(
accessor: TAccessor,
column: TAccessor extends AccessorFn<TData>
? DataTableDisplayColumnDef<TData, TValue> & DataTableSortableColumnDef
: DataTableIdentifiedColumnDef<TData, TValue> & DataTableSortableColumnDef
) => TAccessor extends AccessorFn<TData>
? AccessorFnColumnDef<TData, TValue>
: AccessorKeyColumnDef<TData, TValue>
/**
* Create a display column.
*
* @param column The column to create the display for.
* @returns The created display column.
*/
display: (column: DataTableDisplayColumnDef<TData>) => DisplayColumnDef<TData>
/**
* Create an action column.
*
* @param props The props to create the action column for.
* @returns The created action column.
*/
action: (
props: DataTableActionColumnDef<TData>
) => DisplayColumnDef<TData, unknown>
/**
* Create a select column.
*
* @param props The props to create the select column for.
* @returns The created select column.
*/
select: (
props?: DataTableSelectColumnDef<TData>
) => DisplayColumnDef<TData, unknown>
}
export interface DataTableSortingState extends ColumnSort {}
export interface DataTableRowSelectionState extends RowSelectionState {}
export interface DataTablePaginationState extends PaginationState {}
export type DataTableFilteringState<
T extends Record<string, unknown> = Record<string, unknown>
> = {
[K in keyof T]: T[K]
}
export type DataTableFilterType = "radio" | "select" | "date"
export type DataTableFilterOption<T = string> = {
label: string
value: T
}
interface DataTableBaseFilterProps {
type: DataTableFilterType
label: string
}
export interface DataTableRadioFilterProps extends DataTableBaseFilterProps {
type: "radio"
options: DataTableFilterOption[]
}
export interface DataTableSelectFilterProps extends DataTableBaseFilterProps {
type: "select"
options: DataTableFilterOption[]
}
export interface DataTableDateFilterProps extends DataTableBaseFilterProps {
type: "date"
/**
* The format of the date.
* @default "date"
*/
format?: "date" | "date-time"
/**
* The label to display for the range option.
*/
rangeOptionLabel?: string
/**
* The label to display for the start of the range option.
*/
rangeOptionStartLabel?: string
/**
* The label to display for the end of the range option.
*/
rangeOptionEndLabel?: string
/**
* Whether to disable the range option.
*/
disableRangeOption?: boolean
/**
* A function to format the date value.
*
* @example
* ```tsx
* formatDateValue={(value) => value.toLocaleDateString()}
* ```
*/
formatDateValue?: (value: Date) => string
/**
* The options to display in the filter.
*
* @example
* ```tsx
* options: [
* { label: "Today", value: { $gte: new Date().toISOString() } },
* { label: "Yesterday", value: { $gte: new Date(new Date().getTime() - 24 * 60 * 60 * 1000).toISOString() } },
* ]
* ```
*/
options: DataTableFilterOption<DataTableDateComparisonOperator>[]
}
export type DataTableFilterProps =
| DataTableRadioFilterProps
| DataTableSelectFilterProps
| DataTableDateFilterProps
export type DataTableFilter<
T extends DataTableFilterProps = DataTableFilterProps
> = T & {
id: string
}
export enum DataTableEmptyState {
EMPTY = "EMPTY",
FILTERED_EMPTY = "FILTERED_EMPTY",
POPULATED = "POPULATED",
}
export type DataTableDateComparisonOperator = {
/**
* The filtered date must be greater than or equal to this value.
*/
$gte?: string
/**
* The filtered date must be less than or equal to this value.
*/
$lte?: string
/**
* The filtered date must be less than this value.
*/
$lt?: string
/**
* The filtered date must be greater than this value.
*/
$gt?: string
}
type DataTableCommandAction = (
selection: DataTableRowSelectionState
) => void | Promise<void>
export interface DataTableCommand {
/**
* The label to display in the command bar.
*/
label: string
/**
* The action to perform when the command is selected.
*/
action: DataTableCommandAction
/**
* The shortcut to use for the command.
*
* @example "i"
*/
shortcut: string
}
export type DataTableEmptyStateContent = {
/**
* The heading to display in the empty state.
*/
heading?: string
/**
* The description to display in the empty state.
*/
description?: string
/**
* A custom component to display in the empty state, if provided it will override the heading and description.
*/
custom?: React.ReactNode
}
export type DataTableEmptyStateProps = {
/**
* The empty state to display when the table is filtered, but no rows are found.
*/
filtered?: DataTableEmptyStateContent
/**
* The empty state to display when the table is empty.
*/
empty?: DataTableEmptyStateContent
}
export interface DataTableColumnFilter extends ColumnFilter {}

View File

@@ -0,0 +1,533 @@
import {
ColumnFilter,
ColumnFiltersState,
type ColumnSort,
getCoreRowModel,
PaginationState,
type RowSelectionState,
type SortingState,
type TableOptions,
type Updater,
useReactTable,
} from "@tanstack/react-table";
import * as React from "react";
import {
DataTableColumnDef,
DataTableColumnFilter,
DataTableCommand,
DataTableDateComparisonOperator,
DataTableEmptyState,
DataTableFilter,
DataTableFilteringState,
DataTableFilterOption,
DataTablePaginationState,
DataTableRowSelectionState,
DataTableSortingState,
} from "./types";
interface DataTableOptions<TData>
extends Pick<TableOptions<TData>, "data" | "getRowId"> {
/**
* The columns to use for the table.
*/
columns: DataTableColumnDef<TData, any>[];
/**
* The filters which the user can apply to the table.
*/
filters?: DataTableFilter[]
/**
* The commands which the user can apply to selected rows.
*/
commands?: DataTableCommand[]
/**
* Whether the data for the table is currently being loaded.
*/
isLoading?: boolean
/**
* The state and callback for the filtering.
*/
filtering?: {
state: DataTableFilteringState
onFilteringChange: (state: DataTableFilteringState) => void
}
/**
* The state and callback for the row selection.
*/
rowSelection?: {
state: DataTableRowSelectionState
onRowSelectionChange: (state: DataTableRowSelectionState) => void
}
/**
* The state and callback for the sorting.
*/
sorting?: {
state: DataTableSortingState | null
onSortingChange: (state: DataTableSortingState) => void
}
/**
* The state and callback for the search, with optional debounce.
*/
search?: {
state: string
onSearchChange: (state: string) => void
/**
* Debounce time in milliseconds for the search callback.
* @default 300
*/
debounce?: number
}
/**
* The state and callback for the pagination.
*/
pagination?: {
state: DataTablePaginationState
onPaginationChange: (state: DataTablePaginationState) => void
}
/**
* The function to execute when a row is clicked.
*/
onRowClick?: (
event: React.MouseEvent<HTMLTableRowElement, MouseEvent>,
row: TData
) => void
/**
* The total count of rows. When working with pagination, this will be the total
* number of rows available, not the number of rows currently being displayed.
*/
rowCount?: number
/**
* Whether the page index should be reset the filtering, sorting, or pagination changes.
*
* @default true
*/
autoResetPageIndex?: boolean
}
interface UseDataTableReturn<TData>
extends Pick<
ReturnType<typeof useReactTable<TData>>,
| "getHeaderGroups"
| "getRowModel"
| "getCanNextPage"
| "getCanPreviousPage"
| "nextPage"
| "previousPage"
| "getPageCount"
| "getAllColumns"
> {
getSorting: () => DataTableSortingState | null
setSorting: (
sortingOrUpdater:
| DataTableSortingState
| ((prev: DataTableSortingState | null) => DataTableSortingState)
) => void
getFilters: () => DataTableFilter[]
getFilterOptions: <
T extends string | string[] | DataTableDateComparisonOperator
>(
id: string
) => DataTableFilterOption<T>[] | null
getFilterMeta: (id: string) => DataTableFilter | null
getFiltering: () => DataTableFilteringState
addFilter: (filter: DataTableColumnFilter) => void
removeFilter: (id: string) => void
clearFilters: () => void
updateFilter: (filter: DataTableColumnFilter) => void
getSearch: () => string
onSearchChange: (search: string) => void
getCommands: () => DataTableCommand[]
getRowSelection: () => DataTableRowSelectionState
onRowClick?: (
event: React.MouseEvent<HTMLTableRowElement, MouseEvent>,
row: TData
) => void
emptyState: DataTableEmptyState
isLoading: boolean
showSkeleton: boolean
pageIndex: number
pageSize: number
rowCount: number
enablePagination: boolean
enableFiltering: boolean
enableSorting: boolean
enableSearch: boolean
}
const useDataTable = <TData,>({
rowCount = 0,
filters,
commands,
rowSelection,
sorting,
filtering,
pagination,
search,
onRowClick,
autoResetPageIndex = true,
isLoading = false,
...options
}: DataTableOptions<TData>): UseDataTableReturn<TData> => {
const { state: sortingState, onSortingChange } = sorting ?? {}
const { state: filteringState, onFilteringChange } = filtering ?? {}
const { state: paginationState, onPaginationChange } = pagination ?? {}
const { state: rowSelectionState, onRowSelectionChange } = rowSelection ?? {}
const autoResetPageIndexHandler = React.useCallback(() => {
return autoResetPageIndex
? () =>
paginationState &&
onPaginationChange?.({ ...paginationState, pageIndex: 0 })
: undefined
}, [autoResetPageIndex, paginationState, onPaginationChange])
const sortingStateHandler = React.useCallback(() => {
return onSortingChange
? (updaterOrValue: Updater<SortingState>) => {
autoResetPageIndexHandler()?.()
onSortingChangeTransformer(
onSortingChange,
sortingState
)(updaterOrValue)
}
: undefined
}, [onSortingChange, sortingState, autoResetPageIndexHandler])
const rowSelectionStateHandler = React.useCallback(() => {
return onRowSelectionChange
? (updaterOrValue: Updater<RowSelectionState>) => {
autoResetPageIndexHandler()?.()
onRowSelectionChangeTransformer(
onRowSelectionChange,
rowSelectionState
)(updaterOrValue)
}
: undefined
}, [onRowSelectionChange, rowSelectionState, autoResetPageIndexHandler])
const filteringStateHandler = React.useCallback(() => {
return onFilteringChange
? (updaterOrValue: Updater<ColumnFiltersState>) => {
autoResetPageIndexHandler()?.()
onFilteringChangeTransformer(
onFilteringChange,
filteringState
)(updaterOrValue)
}
: undefined
}, [onFilteringChange, filteringState, autoResetPageIndexHandler])
const paginationStateHandler = React.useCallback(() => {
return onPaginationChange
? onPaginationChangeTransformer(onPaginationChange, paginationState)
: undefined
}, [onPaginationChange, paginationState])
const instance = useReactTable({
...options,
getCoreRowModel: getCoreRowModel(),
state: {
rowSelection: rowSelectionState ?? {},
sorting: sortingState ? [sortingState] : undefined,
columnFilters: Object.entries(filteringState ?? {}).map(
([id, filter]) => ({
id,
value: filter,
})
),
pagination: paginationState,
},
rowCount,
onColumnFiltersChange: filteringStateHandler(),
onRowSelectionChange: rowSelectionStateHandler(),
onSortingChange: sortingStateHandler(),
onPaginationChange: paginationStateHandler(),
manualSorting: true,
manualPagination: true,
manualFiltering: true,
})
const getSorting = React.useCallback(() => {
return instance.getState().sorting?.[0] ?? null
}, [instance])
const setSorting = React.useCallback(
(
sortingOrUpdater: ColumnSort | ((prev: ColumnSort | null) => ColumnSort)
) => {
const currentSort = instance.getState().sorting?.[0] ?? null
const newSorting =
typeof sortingOrUpdater === "function"
? sortingOrUpdater(currentSort)
: sortingOrUpdater
autoResetPageIndexHandler()?.()
instance.setSorting([newSorting])
},
[instance, autoResetPageIndexHandler]
)
const getFilters = React.useCallback(() => {
return filters ?? []
}, [filters])
const getFilterOptions = React.useCallback(
<T extends string | string[] | DataTableDateComparisonOperator>(
id: string
) => {
const filter = getFilters().find((filter) => filter.id === id)
if (!filter) {
return null
}
return filter.options as DataTableFilterOption<T>[]
},
[getFilters]
)
const getFilterMeta = React.useCallback(
(id: string) => {
return getFilters().find((filter) => filter.id === id) || null
},
[getFilters]
)
const getFiltering = React.useCallback(() => {
const state = instance.getState().columnFilters ?? []
return Object.fromEntries(state.map((filter) => [filter.id, filter.value]))
}, [instance])
const addFilter = React.useCallback(
(filter: ColumnFilter) => {
if (filter.value) {
autoResetPageIndexHandler()?.()
}
onFilteringChange?.({ ...getFiltering(), [filter.id]: filter.value })
},
[onFilteringChange, getFiltering, autoResetPageIndexHandler]
)
const removeFilter = React.useCallback(
(id: string) => {
const currentFilters = getFiltering()
delete currentFilters[id]
autoResetPageIndexHandler()?.()
onFilteringChange?.(currentFilters)
},
[onFilteringChange, getFiltering, autoResetPageIndexHandler]
)
const clearFilters = React.useCallback(() => {
autoResetPageIndexHandler()?.()
onFilteringChange?.({})
}, [onFilteringChange, autoResetPageIndexHandler])
const updateFilter = React.useCallback(
(filter: ColumnFilter) => {
addFilter(filter)
},
[addFilter]
)
const { state: searchState, onSearchChange, debounce = 300 } = search ?? {}
const [localSearch, setLocalSearch] = React.useState(searchState ?? "")
const timeoutRef = React.useRef<ReturnType<typeof setTimeout>>()
React.useEffect(() => {
setLocalSearch(searchState ?? "")
}, [searchState])
const getSearch = React.useCallback(() => {
return localSearch
}, [localSearch])
const debouncedSearchChange = React.useMemo(() => {
if (!onSearchChange) {
return undefined
}
return (value: string) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
if (debounce <= 0) {
autoResetPageIndexHandler()?.()
onSearchChange(value)
return
}
timeoutRef.current = setTimeout(() => {
autoResetPageIndexHandler()?.()
onSearchChange(value)
}, debounce)
}
}, [onSearchChange, debounce, autoResetPageIndexHandler])
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const onSearchChangeHandler = React.useCallback(
(search: string) => {
setLocalSearch(search)
debouncedSearchChange?.(search)
},
[debouncedSearchChange]
)
const getCommands = React.useCallback(() => {
return commands ?? []
}, [commands])
const getRowSelection = React.useCallback(() => {
return instance.getState().rowSelection
}, [instance])
const rows = instance.getRowModel().rows
const emptyState = React.useMemo(() => {
const hasRows = rows.length > 0
const hasSearch = Boolean(searchState)
const hasFilters = Object.keys(filteringState ?? {}).length > 0
if (hasRows) {
return DataTableEmptyState.POPULATED
}
if (hasSearch || hasFilters) {
return DataTableEmptyState.FILTERED_EMPTY
}
return DataTableEmptyState.EMPTY
}, [rows, searchState, filteringState])
const showSkeleton = React.useMemo(() => {
return isLoading === true && rows.length === 0
}, [isLoading, rows])
const enablePagination: boolean = !!pagination
const enableFiltering: boolean = !!filtering
const enableSorting: boolean = !!sorting
const enableSearch: boolean = !!search
return {
// Table
getHeaderGroups: instance.getHeaderGroups,
getRowModel: instance.getRowModel,
getAllColumns: instance.getAllColumns,
// Pagination
enablePagination,
getCanNextPage: instance.getCanNextPage,
getCanPreviousPage: instance.getCanPreviousPage,
nextPage: instance.nextPage,
previousPage: instance.previousPage,
getPageCount: instance.getPageCount,
pageIndex: instance.getState()?.pagination?.pageIndex ?? 0,
pageSize: instance.getState()?.pagination?.pageSize ?? 10,
rowCount,
// Search
enableSearch,
getSearch,
onSearchChange: onSearchChangeHandler,
// Sorting
enableSorting,
getSorting,
setSorting,
// Filtering
enableFiltering,
getFilters,
getFilterOptions,
getFilterMeta,
getFiltering,
addFilter,
removeFilter,
clearFilters,
updateFilter,
// Commands
getCommands,
getRowSelection,
// Handlers
onRowClick,
// Empty State
emptyState,
// Loading
isLoading,
showSkeleton,
}
}
function onSortingChangeTransformer(
onSortingChange: (state: ColumnSort) => void,
state?: ColumnSort | null
) {
return (updaterOrValue: Updater<SortingState>) => {
const value =
typeof updaterOrValue === "function"
? updaterOrValue(state ? [state] : [])
: updaterOrValue
const columnSort = value[0]
onSortingChange(columnSort)
}
}
function onRowSelectionChangeTransformer(
onRowSelectionChange: (state: RowSelectionState) => void,
state?: RowSelectionState
) {
return (updaterOrValue: Updater<RowSelectionState>) => {
const value =
typeof updaterOrValue === "function"
? updaterOrValue(state ?? {})
: updaterOrValue
onRowSelectionChange(value)
}
}
function onFilteringChangeTransformer(
onFilteringChange: (state: DataTableFilteringState) => void,
state?: DataTableFilteringState
) {
return (updaterOrValue: Updater<ColumnFiltersState>) => {
const value =
typeof updaterOrValue === "function"
? updaterOrValue(
Object.entries(state ?? {}).map(([id, filter]) => ({
id,
value: filter,
}))
)
: updaterOrValue
const transformedValue = Object.fromEntries(
value.map((filter) => [filter.id, filter])
)
onFilteringChange(transformedValue)
}
}
function onPaginationChangeTransformer(
onPaginationChange: (state: PaginationState) => void,
state?: PaginationState
) {
return (updaterOrValue: Updater<PaginationState>) => {
const value =
typeof updaterOrValue === "function"
? updaterOrValue(state ?? { pageIndex: 0, pageSize: 10 })
: updaterOrValue
onPaginationChange(value)
}
}
export { useDataTable };
export type { DataTableOptions, UseDataTableReturn };

View File

@@ -0,0 +1,76 @@
"use client"
import { createColumnHelper as createColumnHelperTanstack } from "@tanstack/react-table"
import * as React from "react"
import { DataTableActionCell } from "../components/data-table-action-cell"
import {
DataTableSelectCell,
DataTableSelectHeader,
} from "../components/data-table-select-cell"
import {
DataTableActionColumnDef,
DataTableColumnHelper,
DataTableSelectColumnDef,
DataTableSortableColumnDef,
DataTableSortableColumnDefMeta,
} from "../types"
const createDataTableColumnHelper = <
TData,
>(): DataTableColumnHelper<TData> => {
const { accessor: accessorTanstack, display } =
createColumnHelperTanstack<TData>()
return {
accessor: (accessor, column) => {
const {
sortLabel,
sortAscLabel,
sortDescLabel,
meta,
enableSorting,
...rest
} = column as any & DataTableSortableColumnDef
const extendedMeta: DataTableSortableColumnDefMeta = {
___sortMetaData: { sortLabel, sortAscLabel, sortDescLabel },
...(meta || {}),
}
return accessorTanstack(accessor, {
...rest,
enableSorting: enableSorting ?? false,
meta: extendedMeta,
})
},
display,
action: ({ actions, ...props }: DataTableActionColumnDef<TData>) =>
display({
id: "action",
cell: (ctx) => <DataTableActionCell ctx={ctx} />,
meta: {
___actions: actions,
...(props.meta || {}),
},
...props,
}),
select: (props?: DataTableSelectColumnDef<TData>) =>
display({
id: "select",
header: props?.header
? props.header
: (ctx) => <DataTableSelectHeader ctx={ctx} />,
cell: props?.cell
? props.cell
: (ctx) => <DataTableSelectCell ctx={ctx} />,
}),
}
}
const helper = createColumnHelperTanstack()
helper.accessor("name", {
meta: {},
})
export { createDataTableColumnHelper }

View File

@@ -0,0 +1,8 @@
import { DataTableCommand } from "../types"
const createDataTableCommandHelper = () => ({
command: (command: DataTableCommand) => command,
})
export { createDataTableCommandHelper }

View File

@@ -0,0 +1,14 @@
import { DeepKeys } from "@tanstack/react-table"
import { DataTableFilter, DataTableFilterProps } from "../types"
const createDataTableFilterHelper = <TData>() => ({
accessor: (accessor: DeepKeys<TData>, props: DataTableFilterProps) => ({
id: accessor,
...props,
}),
custom: <T extends DataTableFilterProps>(props: DataTableFilter<T>) => props,
})
export { createDataTableFilterHelper }

View File

@@ -0,0 +1,19 @@
import { DataTableDateComparisonOperator } from "../types";
export function isDateComparisonOperator(
value: unknown
): value is DataTableDateComparisonOperator {
if (typeof value !== "object" || value === null) {
return false
}
const validOperators = ["$gte", "$lte", "$gt", "$lt"]
const hasAtLeastOneOperator = validOperators.some((op) => op in value)
const allPropertiesValid = Object.entries(value as Record<string, unknown>)
.every(([key, val]) =>
validOperators.includes(key) && (typeof val === "string" || val === undefined)
)
return hasAtLeastOneOperator && allPropertiesValid
}

View File

@@ -5,13 +5,16 @@ import * as Primitives from "@radix-ui/react-checkbox"
import * as React from "react"
import { clx } from "@/utils/clx"
import { CheckboxCheckedState } from "./types"
/**
* This component is based on the [Radix UI Checkbox](https://www.radix-ui.com/primitives/docs/components/checkbox) primitive.
*/
const Checkbox = React.forwardRef<
React.ElementRef<typeof Primitives.Root>,
React.ComponentPropsWithoutRef<typeof Primitives.Root>
React.ComponentPropsWithoutRef<typeof Primitives.Root> & {
checked?: CheckboxCheckedState | undefined
}
>(({ className, checked, ...props }, ref) => {
return (
<Primitives.Root
@@ -19,13 +22,13 @@ const Checkbox = React.forwardRef<
ref={ref}
checked={checked}
className={clx(
"group relative inline-flex h-5 w-5 items-center justify-center outline-none ",
"group inline-flex h-5 w-5 items-center justify-center outline-none ",
className
)}
>
<div
className={clx(
"text-ui-fg-on-inverted bg-ui-bg-base shadow-borders-base [&_path]:shadow-details-contrast-on-bg-interactive transition-fg h-[14px] w-[14px] rounded-[3px]",
"text-ui-fg-on-inverted bg-ui-bg-base shadow-borders-base [&_path]:shadow-details-contrast-on-bg-interactive transition-fg h-[15px] w-[15px] rounded-[3px]",
"group-disabled:cursor-not-allowed group-disabled:opacity-50",
"group-focus-visible:!shadow-borders-interactive-with-focus",
"group-hover:group-enabled:group-data-[state=unchecked]:bg-ui-bg-base-hover",

View File

@@ -1 +1,2 @@
export * from "./checkbox"
export * from "./types"

View File

@@ -0,0 +1,3 @@
import { CheckedState } from "@radix-ui/react-checkbox"
export type CheckboxCheckedState = CheckedState

View File

@@ -167,7 +167,7 @@ const Command = React.forwardRef<HTMLButtonElement, CommandProps>(
) => {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === shortcut) {
if (event.key.toLowerCase() === shortcut.toLowerCase()) {
event.preventDefault()
event.stopPropagation()
action()

View File

@@ -33,7 +33,6 @@ const datePickerFieldStyles = cva({
const DatePickerField = ({ size = "base", ...props }: DatePickerFieldProps) => {
const { locale } = useLocale()
const state = useDateFieldState({
...props,
locale,
@@ -44,7 +43,12 @@ const DatePickerField = ({ size = "base", ...props }: DatePickerFieldProps) => {
const { fieldProps } = useDateField(props, state, ref)
return (
<div ref={ref} aria-label="Date input" className={datePickerFieldStyles({ size })} {...fieldProps}>
<div
ref={ref}
aria-label="Date input"
className={datePickerFieldStyles({ size })}
{...fieldProps}
>
{state.segments.map((segment, index) => {
return <DateSegment key={index} segment={segment} state={state} />
})}

View File

@@ -48,7 +48,7 @@ const SubMenuTrigger = React.forwardRef<
className={clx(
"bg-ui-bg-component text-ui-fg-base txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors",
"focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover",
"active:bg-ui-bg-component-pressed",
"active:bg-ui-bg-component-hover",
"data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none",
"data-[state=open]:!bg-ui-bg-component-hover",
className
@@ -56,7 +56,7 @@ const SubMenuTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronRightMini className="ml-auto text-ui-fg-muted" />
<ChevronRightMini className="text-ui-fg-muted ml-auto" />
</Primitives.SubTrigger>
))
SubMenuTrigger.displayName = "DropdownMenu.SubMenuTrigger"
@@ -130,7 +130,7 @@ const Item = React.forwardRef<
className={clx(
"bg-ui-bg-component text-ui-fg-base txt-compact-small relative flex cursor-pointer select-none items-center rounded-md px-2 py-1.5 outline-none transition-colors",
"focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover",
"active:bg-ui-bg-component-pressed",
"active:bg-ui-bg-component-hover",
"data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none",
className
)}
@@ -149,9 +149,9 @@ const CheckboxItem = React.forwardRef<
<Primitives.CheckboxItem
ref={ref}
className={clx(
"bg-ui-bg-component text-ui-fg-base relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-[31px] pr-2 txt-compact-small outline-none transition-colors",
"focus-visible:bg-ui-bg-component-pressed",
"active:bg-ui-bg-component-pressed",
"bg-ui-bg-component text-ui-fg-base txt-compact-small relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-[31px] pr-2 outline-none transition-colors",
"focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover",
"active:bg-ui-bg-component-hover",
"data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none",
"data-[state=checked]:txt-compact-small-plus",
className
@@ -180,8 +180,8 @@ const RadioItem = React.forwardRef<
ref={ref}
className={clx(
"bg-ui-bg-component txt-compact-small relative flex cursor-pointer select-none items-center rounded-md py-1.5 pl-[31px] pr-2 outline-none transition-colors",
"focus-visible:bg-ui-bg-component-hover",
"active:bg-ui-bg-component-pressed",
"focus-visible:bg-ui-bg-component-hover focus:bg-ui-bg-component-hover",
"active:bg-ui-bg-component-hover",
"data-[disabled]:text-ui-fg-disabled data-[disabled]:pointer-events-none",
"data-[state=checked]:txt-compact-small-plus",
className
@@ -222,7 +222,10 @@ const Separator = React.forwardRef<
>(({ className, ...props }, ref) => (
<Primitives.Separator
ref={ref}
className={clx("bg-ui-border-component -mx-1 my-1 h-0.5 border-t border-t-ui-border-menu-top border-b border-b-ui-border-menu-bot", className)}
className={clx(
"bg-ui-border-component border-t-ui-border-menu-top border-b-ui-border-menu-bot -mx-1 my-1 h-0.5 border-b border-t",
className
)}
{...props}
/>
))

View File

@@ -7,17 +7,16 @@ import { clx } from "@/utils/clx"
const iconButtonVariants = cva({
base: clx(
"transition-fg relative inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none",
"disabled:bg-ui-bg-disabled disabled:shadow-buttons-neutral disabled:text-ui-fg-disabled disabled:after:hidden"
"transition-fg inline-flex w-fit items-center justify-center overflow-hidden rounded-md outline-none",
"disabled:bg-ui-bg-disabled disabled:shadow-buttons-neutral disabled:text-ui-fg-disabled "
),
variants: {
variant: {
primary: clx(
"shadow-buttons-neutral text-ui-fg-subtle bg-ui-button-neutral after:button-neutral-gradient",
"hover:bg-ui-button-neutral-hover hover:after:button-neutral-hover-gradient",
"active:bg-ui-button-neutral-pressed active:after:button-neutral-pressed-gradient",
"focus-visible:shadow-buttons-neutral-focus",
"after:absolute after:inset-0 after:content-['']"
"shadow-buttons-neutral text-ui-fg-subtle bg-ui-button-neutral",
"hover:bg-ui-button-neutral-hover",
"active:bg-ui-button-neutral-pressed",
"focus-visible:shadow-buttons-neutral-focus"
),
transparent: clx(
"text-ui-fg-subtle bg-ui-button-transparent",

View File

@@ -0,0 +1 @@
export * from "./skeleton";

View File

@@ -0,0 +1,16 @@
import { clx } from "@/utils/clx"
import * as React from "react"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clx("bg-ui-bg-component animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -49,7 +49,7 @@ const Cell = React.forwardRef<
HTMLTableCellElement,
React.HTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={clx("h-12 pr-6", className)} {...props} />
<td ref={ref} className={clx("h-12 py-0 pl-0 pr-6", className)} {...props} />
))
Cell.displayName = "Table.Cell"
@@ -60,7 +60,7 @@ const Header = React.forwardRef<
<thead
ref={ref}
className={clx(
"border-ui-border-base txt-compact-small-plus [&_tr:hover]:bg-ui-bg-base border-y",
"border-ui-border-base txt-compact-small-plus [&_tr]:bg-ui-bg-subtle [&_tr]:hover:bg-ui-bg-subtle border-y",
className
)}
{...props}
@@ -74,7 +74,10 @@ const HeaderCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={clx("txt-compact-small-plus h-12 pr-6 text-left", className)}
className={clx(
"txt-compact-small-plus h-12 py-0 pl-0 pr-6 text-left",
className
)}
{...props}
/>
))

View File

@@ -1,9 +1,10 @@
// Components
export { Alert } from "./components/alert"
export { Avatar } from "./components/avatar"
export { Badge } from "./components/badge"
export { Button } from "./components/button"
export { Calendar } from "./components/calender"
export { Checkbox } from "./components/checkbox"
export { Checkbox, type CheckboxCheckedState } from "./components/checkbox"
export { Code } from "./components/code"
export { CodeBlock } from "./components/code-block"
export { Command } from "./components/command"
@@ -29,6 +30,7 @@ export { ProgressTabs } from "./components/progress-tabs"
export { Prompt } from "./components/prompt"
export { RadioGroup } from "./components/radio-group"
export { Select } from "./components/select"
export { Skeleton } from "./components/skeleton"
export { StatusBadge } from "./components/status-badge"
export { Switch } from "./components/switch"
export { Table } from "./components/table"
@@ -39,6 +41,9 @@ export { Toast } from "./components/toast"
export { Toaster } from "./components/toaster"
export { Tooltip, TooltipProvider } from "./components/tooltip"
// Blocks
export * from "./blocks/data-table"
// Hooks
export { usePrompt } from "./hooks/use-prompt"
export { useToggleState } from "./hooks/use-toggle-state"

View File

@@ -3,8 +3,8 @@
@tailwind utilities;
@layer base {
body {
@apply !bg-ui-bg-base;
:root {
@apply bg-ui-bg-subtle text-ui-fg-base antialiased;
text-rendering: optimizeLegibility;
}
}

View File

@@ -22,7 +22,8 @@
"@/providers/*": ["./src/providers/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/utils/*": ["./src/utils/*"],
"@/types": ["./src/types"]
"@/types": ["./src/types"],
"@/blocks/*": ["./src/blocks/*"]
}
},
"include": ["src", ".eslintrc.js"],

View File

@@ -8,6 +8,7 @@ export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@/blocks": "/src/blocks",
"@/components": "/src/components",
"@/providers": "/src/providers",
"@/hooks": "/src/hooks",