Skip to content
2 changes: 1 addition & 1 deletion src/lib/ui/core/Checkbox/Checkbox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
bind:checked={isActive}
bind:indeterminate
class={cn(
'flex size-4 min-w-4 items-center justify-center rounded border border-mystic bg-[inherit] fill-athens-day transition-colors',
'flex size-4 min-w-4 items-center justify-center rounded border border-mystic bg-[inherit] fill-athens-day',
'hover:border-casper hover:bg-athens group-hover/label:border-casper group-hover/label:bg-[inherit]',
error && 'relative border-red hover:border-red-hover group-hover/label:border-red-hover',
selected &&
Expand Down
165 changes: 137 additions & 28 deletions src/lib/ui/core/Table/DataTable/DataTable.svelte
Original file line number Diff line number Diff line change
@@ -1,63 +1,172 @@
<script
lang="ts"
generics="GItem extends Record<string, unknown>, GColumn extends BaseTableColumn<GItem>"
>
import type { BaseTableColumn } from './types.js'
<script lang="ts" generics="GItem extends any, GColumn extends ColumnDef<any, any, any>">
import type { ComponentProps } from 'svelte'
import type { ColumnDef, SortDirection } from './types.js'

import { cn } from '$ui/utils/index.js'

import Table from '../Table.svelte'
import TableBody from '../TableBody.svelte'
import TableCell from '../TableCell.svelte'
import TableHead from '../TableHead.svelte'
import TableHeader from '../TableHeader.svelte'
import TableRow from '../TableRow.svelte'
import Pagination from '../Pagination.svelte'

type TProps = {
items: GItem[]
columns: GColumn[]

minRows?: number

wrapperClass?: string
class?: string
headerClass?: string
bodyClass?: string
headerRowClass?: string
bodyRowClass?: string

pagination?: ComponentProps<typeof Pagination>

sortColumn?: GColumn
sortDirection?: SortDirection
preValidateSort?: (column: GColumn) => boolean
onSort?: (column: GColumn, direction: SortDirection) => void
}

const {
items,
columns,
wrapperClass,
class: className,
headerClass,
bodyClass,
headerRowClass,
bodyRowClass,
pagination,
minRows = pagination?.pageSize,
preValidateSort,
onSort,
}: TProps = $props()

let sortColumn = $state<GColumn | null>(null)
let sortDirection = $state<SortDirection>('DESC')

let rowHeight = $state(0)

const sortedItems = $derived.by(() => {
if (!sortColumn) return items
const { sortAccessor } = sortColumn
if (!sortAccessor) return items

return items.toSorted((a, b) =>
sortDirection === 'ASC'
? sortAccessor(a) - sortAccessor(b)
: sortAccessor(b) - sortAccessor(a),
)
})

const itemsCount = $derived(pagination?.totalItems ?? items.length)

const pagedItems = $derived.by(() => {
if (!pagination) return sortedItems

const { totalItems, page, pageSize } = pagination

const hasMoreItems = items.length !== totalItems
const pageOffset = (page - 1) * pageSize
const pageEndOffset = pageOffset + pageSize

if (hasMoreItems) return sortedItems.slice(0, pageSize)

return sortedItems.slice(pageOffset, pageEndOffset)
})

const padRowsAmount = $derived(minRows ? minRows - pagedItems.length : 0)

function setSort(column: GColumn) {
if (!column.isSortable) return
applySort(column)
onSort?.(column, sortDirection)
}

function applySort(column: GColumn) {
const isValid = preValidateSort?.(column) ?? true
if (!isValid) return

if (column.id !== sortColumn?.id) {
sortDirection = 'DESC'
sortColumn = column
} else {
sortDirection = sortDirection === 'DESC' ? 'ASC' : 'DESC'
}
}
</script>

<Table class={className}>
<TableHeader class={headerClass}>
<TableRow class={headerRowClass}>
{#each columns as { title, Head, class: className }}
{#if Head}
<Head />
{:else}
<TableHead class={className}>{title}</TableHead>
{/if}
{/each}
</TableRow>
</TableHeader>
<TableBody class={bodyClass}>
{#each items as item, i}
<TableRow class={bodyRowClass}>
<article class={cn('flex w-full flex-col gap-4', wrapperClass)}>
<Table class={className}>
<TableHeader class={headerClass}>
<TableRow class={headerRowClass}>
{#each columns as column}
{#if column.Cell}
<column.Cell {item} />
{@const { id, title, Head, class: className, getHeadProps, isSortable } = column}
{@const isSorted = sortColumn?.id === column.id}

{#if Head}
<Head {column} {...getHeadProps?.()} />
{:else}
<TableCell class={column.class}>
{column.format(item, i)}
</TableCell>
<TableHead
class={className}
onclick={() => setSort(column)}
sortDirection={isSorted ? sortDirection : undefined}
{isSortable}
>
{title || id}
</TableHead>
{/if}
{/each}
</TableRow>
{/each}
</TableBody>
</Table>
</TableHeader>
<TableBody class={bodyClass}>
{#each pagedItems as item, i}
<TableRow bind:height={rowHeight} class={bodyRowClass}>
{#each columns as column}
{@const { id, Cell, format, class: className, getCellProps } = column}

{#if Cell}
<Cell {item} {column} {...getCellProps?.(item)} />
{:else if format}
<TableCell class={className}>
{format(item, i, column)}
</TableCell>
{:else}
<TableCell>
<pre>Declare <code>Cell</code> or <code>format</code> for column <code>{id}</code
></pre>
</TableCell>
{/if}
{/each}
</TableRow>
{/each}

{#each { length: padRowsAmount } as _}
<TableRow style="height: {rowHeight}px;">
{#each columns as _}
<TableCell noStyles />
{/each}
</TableRow>
{/each}
</TableBody>
</Table>

{#if pagination}
{@const { page, pageSize, rows, onPageChange } = pagination}

<Pagination
class={pagination.class}
{page}
{pageSize}
{rows}
{onPageChange}
totalItems={itemsCount}
/>
{/if}
</article>
62 changes: 39 additions & 23 deletions src/lib/ui/core/Table/DataTable/types.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,41 @@
import type { Component } from 'svelte'
import type { Component, ComponentProps } from 'svelte'

// TODO: Maybe it's not worth it to use union for format/Cell and title/Head options
export type BaseTableColumn<GItem extends Record<string, unknown>> = {
type BaseHeadProps<GItem, GCellExtra = object, GHeaderExtra = object> = {
column: ColumnDef<GItem, GCellExtra, GHeaderExtra>
}

type BaseCellProps<GItem, GCellExtra = object, GHeaderExtra = object> = {
item: GItem
column: ColumnDef<GItem, GCellExtra, GHeaderExtra>
}

export type ComponentExtraProps<T extends Component<any, any, any>> = Omit<
ComponentProps<T>,
keyof BaseCellProps<any, any, any>
>

export type ColumnDef<GItem, GCellExtra = object, GHeaderExtra = object> = {
id: string
class?: string
} & (
| {
Cell: Component<{ item: GItem }>
format?: undefined
}
| {
Cell?: undefined
format: (item: GItem, index: number) => string | number
}
) &
(
| {
title: string
Head?: undefined
}
| {
title?: undefined
Head: Component
}
)

title?: string
Head?: Component<BaseHeadProps<GItem, GCellExtra, GHeaderExtra> & GHeaderExtra>
getHeadProps?: () => GHeaderExtra

format?: (
item: GItem,
index: number,
column: ColumnDef<GItem, GCellExtra, GHeaderExtra>,
) => string | number
Cell?: Component<BaseCellProps<GItem, GCellExtra, GHeaderExtra> & GCellExtra>
getCellProps?: (item: GItem) => GCellExtra

isSortable?: boolean
sortAccessor?: (item: GItem) => number
}

export const defineColumn = <GItem, GCellExtra = object, GHeaderExtra = object>(
col: ColumnDef<GItem, GCellExtra, GHeaderExtra>,
) => col

export type SortDirection = 'DESC' | 'ASC'
Loading