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:
committed by
GitHub
parent
c3976a312b
commit
147c0e5a35
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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 />,
|
||||
}
|
||||
@@ -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 }
|
||||
25
packages/design-system/ui/src/blocks/data-table/index.ts
Normal file
25
packages/design-system/ui/src/blocks/data-table/index.ts
Normal 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"
|
||||
342
packages/design-system/ui/src/blocks/data-table/types.ts
Normal file
342
packages/design-system/ui/src/blocks/data-table/types.ts
Normal 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 {}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DataTableCommand } from "../types"
|
||||
|
||||
const createDataTableCommandHelper = () => ({
|
||||
command: (command: DataTableCommand) => command,
|
||||
})
|
||||
|
||||
export { createDataTableCommandHelper }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./checkbox"
|
||||
export * from "./types"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { CheckedState } from "@radix-ui/react-checkbox"
|
||||
|
||||
export type CheckboxCheckedState = CheckedState
|
||||
@@ -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()
|
||||
|
||||
@@ -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} />
|
||||
})}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./skeleton";
|
||||
@@ -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 }
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@/blocks": "/src/blocks",
|
||||
"@/components": "/src/components",
|
||||
"@/providers": "/src/providers",
|
||||
"@/hooks": "/src/hooks",
|
||||
|
||||
Reference in New Issue
Block a user