import { render } from 'preact'
import { useState } from 'preact/hooks'
import {
columnFacetingFeature,
columnFilteringFeature,
constructFilterFn,
createColumnHelper,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createPaginatedRowModel,
filterFn_includesString,
metaHelper,
rowPaginationFeature,
tableFeatures,
useTable,
} from '@tanstack/preact-table'
import './index.css'
import { dataReferenceDate, makeData } from './makeData'
import type { Column, PreactTable } from '@tanstack/preact-table'
import type { Account } from './makeData'
type FacetKey = string
type Bucket<TValue> = {
value: FacetKey
label: string
test: (value: TValue) => boolean
}
type FacetOption = Pick<Bucket<unknown>, 'value' | 'label'>
interface MyColumnMeta {
filterVariant?: 'text' | 'facets'
facetOptions?: ReadonlyArray<FacetOption>
}
const now = dataReferenceDate
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const startOfYesterday = new Date(startOfToday)
startOfYesterday.setDate(startOfYesterday.getDate() - 1)
const startOfWeek = new Date(startOfToday)
const daysSinceMonday = (startOfWeek.getDay() + 6) % 7
startOfWeek.setDate(startOfWeek.getDate() - daysSinceMonday)
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1)
const lastLoginBuckets = [
{
value: 'today',
label: 'Today',
test: (value: Date) => value >= startOfToday,
},
{
value: 'yesterday',
label: 'Yesterday',
test: (value: Date) => value >= startOfYesterday,
},
{
value: 'this-week',
label: 'This week',
test: (value: Date) => value >= startOfWeek,
},
{
value: 'this-month',
label: 'This month',
test: (value: Date) => value >= startOfMonth,
},
{
value: 'older',
label: 'Older',
test: () => true,
},
] satisfies ReadonlyArray<Bucket<Date>>
const GB = 1024 ** 3
const storageBuckets = [
{
value: 'under-1-gb',
label: '< 1 GB',
test: (value: number) => value < GB,
},
{
value: '1-to-10-gb',
label: '1–10 GB',
test: (value: number) => value < 10 * GB,
},
{
value: '10-to-100-gb',
label: '10–100 GB',
test: (value: number) => value < 100 * GB,
},
{
value: '100-gb-plus',
label: '100+ GB',
test: () => true,
},
] satisfies ReadonlyArray<Bucket<number>>
function getBucket<TValue>(
value: TValue,
buckets: ReadonlyArray<Bucket<TValue>>,
): FacetKey {
const bucket = buckets.find((candidate) => candidate.test(value))
if (!bucket) {
throw new Error(`No facet bucket matched ${String(value)}`)
}
return bucket.value
}
function createBucketFilter<TValue>(buckets: ReadonlyArray<Bucket<TValue>>) {
return constructFilterFn({
resolveDataValue: (value) => getBucket(value as TValue, buckets),
filter: (bucketValue, selectedBuckets: ReadonlyArray<FacetKey>) =>
selectedBuckets.includes(bucketValue),
autoRemove: (selectedBuckets: ReadonlyArray<FacetKey>) =>
!selectedBuckets.length,
})
}
const features = tableFeatures({
columnFacetingFeature,
columnFilteringFeature,
rowPaginationFeature,
filteredRowModel: createFilteredRowModel(),
paginatedRowModel: createPaginatedRowModel(),
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
filterFns: {
includesString: filterFn_includesString,
},
columnMeta: metaHelper<MyColumnMeta>(),
})
const columnHelper = createColumnHelper<typeof features, Account>()
const columns = columnHelper.columns([
columnHelper.accessor('name', {
header: 'Account',
filterFn: 'includesString',
meta: { filterVariant: 'text' },
}),
columnHelper.accessor('lastLogin', {
header: 'Last login',
cell: (info) => info.getValue().toLocaleString(),
getUniqueValues: (row) => [getBucket(row.lastLogin, lastLoginBuckets)],
filterFn: createBucketFilter(lastLoginBuckets),
meta: {
filterVariant: 'facets',
facetOptions: lastLoginBuckets,
},
}),
columnHelper.accessor('storageBytes', {
header: 'Storage',
cell: (info) => formatBytes(info.getValue()),
getUniqueValues: (row) => [getBucket(row.storageBytes, storageBuckets)],
filterFn: createBucketFilter(storageBuckets),
meta: {
filterVariant: 'facets',
facetOptions: storageBuckets,
},
}),
columnHelper.accessor('files', {
header: 'Files',
enableColumnFilter: false,
cell: (info) => info.getValue().toLocaleString(),
}),
])
function App() {
const [data, setData] = useState<Array<Account>>(() => makeData(5_000))
const refreshData = () => setData(makeData(5_000))
const stressTest = () => setData(makeData(1_000_000))
const table = useTable(
{
features,
columns,
data,
debugTable: true,
debugHeaders: true,
debugColumns: false,
},
(state) => state,
)
return (
<div className="demo-root">
<h1>Bucketed faceted filters</h1>
<p>
Bucket continuous values without a hidden derived column. Select more
than one bucket to match either value; counts react to filters on the
other columns.
</p>
<div>
<button onClick={() => refreshData()}>Regenerate Data</button>
<button onClick={() => stressTest()}>Stress Test (1M rows)</button>
<button onClick={() => table.resetColumnFilters()}>
Clear all filters
</button>
</div>
<div className="spacer-sm" />
<div className="table-container">
<table>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder ? null : (
<>
<div className="header-label">
<table.FlexRender header={header} />
</div>
{header.column.getCanFilter() ? (
<Filter column={header.column} table={table} />
) : null}
</>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getAllCells().map((cell) => (
<td key={cell.id}>
<table.FlexRender cell={cell} />
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="pagination">
<button
className="demo-button demo-button-sm"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
{'<<'}
</button>
<button
className="demo-button demo-button-sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
{'<'}
</button>
<button
className="demo-button demo-button-sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
{'>'}
</button>
<button
className="demo-button demo-button-sm"
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
{'>>'}
</button>
<span className="inline-controls">
<div>Page</div>
<strong>
{(table.state.pagination.pageIndex + 1).toLocaleString()} of{' '}
{table.getPageCount().toLocaleString()}
</strong>
</span>
<span className="inline-controls">
| Go to page:
<input
type="number"
min="1"
max={table.getPageCount()}
defaultValue={table.state.pagination.pageIndex + 1}
onChange={(event) => {
const page = event.currentTarget.value
? Number(event.currentTarget.value) - 1
: 0
table.setPageIndex(page)
}}
className="page-size-input"
/>
</span>
<select
value={table.state.pagination.pageSize}
onChange={(event) =>
table.setPageSize(Number(event.currentTarget.value))
}
>
{[10, 20, 30, 40, 50].map((pageSize) => (
<option key={pageSize} value={pageSize}>
Show {pageSize}
</option>
))}
</select>
</div>
<div>
{table.getPrePaginatedRowModel().rows.length.toLocaleString()} Rows
</div>
<div></div>
<pre>{JSON.stringify(table.state, null, 2)}</pre>
</div>
)
}
function Filter({
column,
table,
}: {
column: Column<typeof features, Account>
table: PreactTable<typeof features, Account>
}) {
const { filterVariant, facetOptions } = column.columnDef.meta ?? {}
return (
<table.Subscribe selector={(state) => state.columnFilters}>
{() =>
filterVariant === 'facets' && facetOptions ? (
<FacetFilter
column={column}
options={facetOptions}
selected={(column.getFilterValue() ?? []) as Array<FacetKey>}
counts={column.getFacetedUniqueValues()}
/>
) : (
<input
aria-label={`Filter ${column.id}`}
className="text-filter"
value={(column.getFilterValue() ?? '') as string}
onChange={(event) =>
column.setFilterValue(event.currentTarget.value)
}
placeholder="Search…"
/>
)
}
</table.Subscribe>
)
}
function FacetFilter({
column,
options,
selected,
counts,
}: {
column: Column<typeof features, Account>
options: ReadonlyArray<FacetOption>
selected: Array<FacetKey>
counts: Map<unknown, number>
}) {
const toggleBucket = (value: FacetKey) => {
column.setFilterValue(
selected.includes(value)
? selected.filter((selectedValue) => selectedValue !== value)
: [...selected, value],
)
}
return (
<fieldset className="facet-options">
<legend className="sr-only">Filter {column.id}</legend>
{options.map((option) => (
<label key={option.value}>
<input
type="checkbox"
checked={selected.includes(option.value)}
onChange={() => toggleBucket(option.value)}
/>
<span>{option.label}</span>
<span className="count">
{(counts.get(option.value) ?? 0).toLocaleString()}
</span>
</label>
))}
</fieldset>
)
}
function formatBytes(value: number) {
if (value < GB) return `${(value / 1024 ** 2).toFixed(0)} MB`
return `${(value / GB).toFixed(1)} GB`
}
const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
render(<App />, rootElement)