Infinite Data Loading
Infinite Row Filtering
Infinitely load filtered rows on demand from the server.
Note
This guide covers filtering on infinite server row data. For client-side filtering, start with the Filter Text guide.
Filtering Infinite Rows
To filter rows on the server, define a filter model and include it in each data request.
Include the filter model in the queryKey of the useServerDataSource hook.
Whenever the filter model changes, the grid requests a new set of rows from the server as the user scrolls.
The following demo passes a custom filter model to the queryKey of the server data source. Click the
funnel icon in a column header to apply a filter.
Filter Infinite Rows
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import {4 Grid,5 usePiece,6 useServerDataSource,7 type DataResponse,8 type PieceWritable,9} from "@1771technologies/lytenyte-pro";10
11import { useCallback, useMemo, useRef, useState } from "react";12import { Server } from "./server.jsx";13import type { MovieData } from "./data.js";14import {15 GenreRenderer,16 Header,17 LinkRenderer,18 NameCellRenderer,19 RatingRenderer,20 ReleasedRenderer,21 TypeRenderer,22} from "./components.jsx";23import type { GridFilter } from "./types.js";24
25export interface GridSpec {26 readonly data: MovieData;27 readonly api: {28 readonly filterModel: PieceWritable<Record<string, GridFilter>>;29 };30}31
32const columns: Grid.Column<GridSpec>[] = [33 {34 id: "#",35 name: "",36 width: 30,37 field: "link",38 widthMin: 30,39 widthMax: 30,40 cellRenderer: LinkRenderer,41 headerRenderer: () => <div />,42 },43 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },44 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer, type: "date" },45 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },46 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },47 { id: "imdb_rating", type: "number", name: "Rating", width: 120, cellRenderer: RatingRenderer },48];49
50const base: Grid.ColumnBase<GridSpec> = { headerRenderer: Header };51
52export default function PaginationDemo() {53 const [filters, setFilters] = useState<Record<string, GridFilter>>({});54 const responseCache = useRef<Record<number, DataResponse[]>>({});55
56 const setFilterAndResetCache: typeof setFilters = useCallback((arg) => {57 setFilters(arg);58 responseCache.current = {};59 }, []);60
61 const model = usePiece(filters, setFilterAndResetCache);62
63 const ds = useServerDataSource({64 queryFn: async ({ requests, queryKey }) => {65 const filter = queryKey[0];66
67 return await Server(requests, filter);68 },69 queryKey: [filters] as const,70 });71
72 const isLoading = ds.isLoading.useValue();73 const apiExtension = useMemo(() => ({ filterModel: model }), [model]);74
75 return (76 <div className="ln-grid" style={{ height: 500 }}>77 <Grid78 rowSource={ds}79 apiExtension={apiExtension}80 columnBase={base}81 columns={columns}82 events={useMemo<Grid.Events<GridSpec>>(() => {83 return {84 viewport: {85 scrollEnd: ({ viewport }) => {86 const top = viewport.scrollTop;87 const left = viewport.scrollHeight - viewport.clientHeight - top;88 if (left < 100) {89 const req = ds.requestsForView.get().at(-1)!;90 const next = { ...req, start: req.end, end: req.end + 100 };91 ds.pushRequests([next]);92 }93 },94 },95 };96 }, [ds])}97 styles={useMemo(() => {98 return { viewport: { style: { scrollbarGutter: "stable" } } };99 }, [])}100 slotViewportOverlay={101 isLoading && (102 <div className="bg-ln-gray-20/40 top-(--ln-top-offset) absolute left-0 z-20 h-[calc(100%-var(--ln-top-offset))] w-full animate-pulse"></div>103 )104 }105 />106 </div>107 );108}1import { format } from "date-fns";2import type { JSX } from "react";3import { Rating, ThinRoundedStar } from "@smastrom/react-rating";4import "@smastrom/react-rating/style.css";5import { Link1Icon } from "@radix-ui/react-icons";6import type { Grid } from "@1771technologies/lytenyte-pro";7import type { GridSpec } from "./demo";8import { Popover } from "@1771technologies/lytenyte-pro/components";9import { FilterControl } from "./filter.js";10
11function SkeletonLoading() {12 return (13 <div className="h-full w-full p-2">14 <div className="bg-ln-gray-20 h-full w-full animate-pulse rounded-xl"></div>15 </div>16 );17}18
19export const NameCellRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {20 if (params.row.loading && !params.row.data) return <SkeletonLoading />;21
22 const field = params.api.columnField(params.column, params.row) as string;23
24 return <div className="overflow-hidden text-ellipsis">{field}</div>;25};26
27export const ReleasedRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {28 if (params.row.loading && !params.row.data) return <SkeletonLoading />;29 const field = params.api.columnField(params.column, params.row) as string;30
31 const formatted = field ? format(field, "dd MMM yyyy") : "-";32
33 return <div>{formatted}</div>;34};35
36export const GenreRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {37 if (params.row.loading && !params.row.data) return <SkeletonLoading />;38 const field = params.api.columnField(params.column, params.row) as string;39
40 const splits = field ? field.split(",") : [];41
42 return (43 <div className="flex h-full w-full items-center gap-1">44 {splits.map((c) => {45 return (46 <div47 className="border-(--primary-200) text-(--primary-700) dark:text-(--primary-500) bg-(--primary-200)/20 rounded border p-1 px-2 text-xs"48 key={c}49 >50 {c}51 </div>52 );53 })}54 </div>55 );56};57
58const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {59 return (60 <svg61 xmlns="http://www.w3.org/2000/svg"62 width="20"63 height="20"64 fill="currentcolor"65 viewBox="0 0 256 256"66 {...props}67 >68 <path d="M232,216H183.36A103.95,103.95,0,1,0,128,232H232a8,8,0,0,0,0-16ZM40,128a88,88,0,1,1,88,88A88.1,88.1,0,0,1,40,128Zm88-24a24,24,0,1,0-24-24A24,24,0,0,0,128,104Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,128,72Zm24,104a24,24,0,1,0-24,24A24,24,0,0,0,152,176Zm-32,0a8,8,0,1,1,8,8A8,8,0,0,1,120,176Zm56-24a24,24,0,1,0-24-24A24,24,0,0,0,176,152Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,176,120ZM80,104a24,24,0,1,0,24,24A24,24,0,0,0,80,104Zm0,32a8,8,0,1,1,8-8A8,8,0,0,1,80,136Z"></path>69 </svg>70 );71};72
73const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {74 return (75 <svg76 xmlns="http://www.w3.org/2000/svg"77 width="20"78 height="20"79 fill="currentcolor"80 viewBox="0 0 256 256"81 {...props}82 >83 <path d="M208,40H48A24,24,0,0,0,24,64V176a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V64A24,24,0,0,0,208,40Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H208a8,8,0,0,1,8,8Zm-48,48a8,8,0,0,1-8,8H96a8,8,0,0,1,0-16h64A8,8,0,0,1,168,224Zm-3.56-110.66-48-32A8,8,0,0,0,104,88v64a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM120,137.05V103l25.58,17Z"></path>84 </svg>85 );86};87
88export const TypeRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {89 if (params.row.loading && !params.row.data) return <SkeletonLoading />;90 const field = params.api.columnField(params.column, params.row) as string;91
92 const isMovie = field === "Movie";93 const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;94
95 return (96 <div className="flex h-full w-full items-center gap-2">97 <span className={isMovie ? "text-(--primary-500)" : "text-ln-primary-50"}>98 <Icon />99 </span>100 <span>{field}</span>101 </div>102 );103};104
105export const RatingRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {106 if (params.row.loading && !params.row.data) return <SkeletonLoading />;107 const field = params.api.columnField(params.column, params.row) as string;108 const rating = field ? Number.parseFloat(field.split("/")[0]) : null;109 if (rating == null || Number.isNaN(rating)) return "-";110
111 return (112 <div className="flex h-full w-full items-center">113 <Rating114 style={{ maxWidth: 100 }}115 halfFillMode="svg"116 value={Math.round(rating / 2)}117 itemStyles={{118 activeFillColor: "hsla(173, 78%, 34%, 1)",119 itemShapes: ThinRoundedStar,120 inactiveFillColor: "transparent",121 inactiveBoxBorderColor: "transparent",122 inactiveBoxColor: "transparent",123 inactiveStrokeColor: "transparent",124 }}125 readOnly126 />127 </div>128 );129};130
131export const LinkRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {132 if (params.row.loading && !params.row.data) return <SkeletonLoading />;133 const field = params.api.columnField(params.column, params.row) as string;134
135 return (136 <a href={field} className="text-(--primary-500)">137 <Link1Icon />138 </a>139 );140};141
142export function Header({ api, column }: Grid.T.HeaderParams<GridSpec>) {143 const label = column.name ?? column.id;144
145 const model = api.filterModel.useValue();146 const hasFilter = !!model[column.id];147 return (148 <div className="flex h-full w-full items-center justify-between">149 <div>{label}</div>150
151 <Popover>152 <Popover.Trigger data-ln-button="secondary" data-ln-icon data-ln-size="sm" className="relative">153 <div className="sr-only">Filter the {label}</div>154 <svg155 xmlns="http://www.w3.org/2000/svg"156 width="16"157 height="16"158 fill="currentcolor"159 viewBox="0 0 256 256"160 >161 <path d="M230.6,49.53A15.81,15.81,0,0,0,216,40H40A16,16,0,0,0,28.19,66.76l.08.09L96,139.17V216a16,16,0,0,0,24.87,13.32l32-21.34A16,16,0,0,0,160,194.66V139.17l67.74-72.32.08-.09A15.8,15.8,0,0,0,230.6,49.53ZM40,56h0Zm106.18,74.58A8,8,0,0,0,144,136v58.66L112,216V136a8,8,0,0,0-2.16-5.47L40,56H216Z"></path>162 </svg>163
164 {hasFilter && <div className="bg-ln-primary-50 absolute right-px top-px size-2 rounded-full" />}165 </Popover.Trigger>166 <Popover.Container>167 <Popover.Arrow />168 <Popover.Title className="sr-only">Filter {label}</Popover.Title>169 <Popover.Description className="sr-only">Filter the text in the{label}</Popover.Description>170 <FilterControl column={column} filter={model[column.id] ?? null} api={api} />171 </Popover.Container>172 </Popover>173 </div>174 );175}1import "@1771technologies/lytenyte-pro/components.css";2import { type Grid } from "@1771technologies/lytenyte-pro";3import type { GridSpec } from "./demo";4import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons";5import { useState } from "react";6import type { GridFilter } from "./types";7import clsx from "clsx";8import { Popover, SmartSelect } from "@1771technologies/lytenyte-pro/components";9
10const textOptions = [11 { id: "equals", label: "Equals" },12 { id: "not_equals", label: "Does Not Equal" },13 { id: "contains", label: "Contains" },14 { id: "not_contains", label: "Does Not Contain" },15];16
17const numberOptions = [18 { id: "equals", label: "Equals" },19 { id: "not_equals", label: "Does Not Equal" },20 { id: "less_than", label: "Less Than" },21 { id: "less_than_or_equal", label: "Less Than Or Equal To" },22 { id: "greater_than", label: "Greater Than" },23 { id: "greater_than_or_equal", label: "Greater Than Or Equal To" },24];25
26const dateOptions = [27 { id: "before", label: "Before" },28 { id: "after", label: "After" },29];30
31type DeepPartial<T> = T extends object32 ? {33 -readonly [P in keyof T]?: DeepPartial<T[P]>;34 }35 : T;36
37export function FilterControl({38 api,39 filter: initialFilter,40 column,41}: {42 api: Grid.API<GridSpec>;43 filter: GridFilter | null;44 column: Grid.Column<GridSpec>;45}) {46 const inputType = column.type === "date" ? "date" : column.type === "number" ? "number" : "text";47 const options =48 column.type === "date" ? dateOptions : column.type === "number" ? numberOptions : textOptions;49
50 const [filter, setFilter] = useState<DeepPartial<GridFilter> | null>(initialFilter);51
52 const canSubmit = filter?.value && filter.operator;53
54 const filterValue = options.find((x) => x.id === filter?.operator);55
56 const popoverControls = Popover.useControls();57 return (58 <form59 className="grid grid-cols-1 gap-2 md:grid-cols-2"60 onSubmit={(e) => {61 if (!canSubmit) return;62 e.preventDefault();63
64 api.filterModel.set((prev) => ({ ...prev, [column.id]: filter as GridFilter }));65 popoverControls.openChange(false);66 }}67 >68 <div className="text-ln-text hidden ps-2 text-sm md:block">Operator</div>69 <div className="text-ln-text hidden ps-2 text-sm md:block">Values</div>70
71 <SmartSelect72 options={options}73 value={filter ? (textOptions.find((x) => x.id === filter.operator) ?? null) : null}74 onOptionChange={(option) => {75 if (!option) return;76
77 setFilter((prev) => {78 return { ...prev, kind: inputType, operator: option.id } as GridFilter;79 });80 }}81 kind="basic"82 trigger={83 <SmartSelect.BasicTrigger84 type="button"85 data-ln-input86 className="flex min-w-40 cursor-pointer items-center justify-between"87 >88 <div>{filterValue?.label ?? "Select..."}</div>89 <div>90 <ChevronDownIcon />91 </div>92 </SmartSelect.BasicTrigger>93 }94 >95 {(p) => {96 if (p.option.id.startsWith("separator")) {97 return <div role="separator" className="bg-ln-gray-40 my-1 h-px w-full" />;98 }99
100 return (101 <SmartSelect.Option key={p.option.id} {...p} className="flex items-center justify-between">102 {p.option.label}103 {p.selected && <CheckIcon className="text-ln-primary-50" />}104 </SmartSelect.Option>105 );106 }}107 </SmartSelect>108
109 <div>110 <label>111 <span className="sr-only">Value for the first filter</span>112 <input113 data-ln-input114 value={filter?.value ?? ""}115 className={clsx("w-full", inputType === "date" && "text-xs")}116 type={inputType}117 onChange={(e) => {118 setFilter((prev) => {119 return {120 ...prev,121 kind: inputType,122 value:123 inputType === "number"124 ? e.target.value125 ? Number.parseFloat(e.target.value)126 : null127 : e.target.value,128 } as GridFilter;129 });130 }}131 />132 </label>133 </div>134
135 <div className="flex items-center justify-between gap-4 md:col-span-2 md:grid md:grid-cols-subgrid">136 <div className="pt-2">137 <button138 data-ln-button="tertiary"139 data-ln-size="sm"140 type="button"141 className="hover:bg-ln-gray-30"142 onClick={() => popoverControls.openChange(false)}143 >144 Cancel145 </button>146 </div>147 <div className="flex justify-end gap-2 pt-2">148 <button149 data-ln-button="secondary"150 data-ln-size="sm"151 type="button"152 className="hover:bg-ln-bg-button-light"153 onClick={() => {154 api.filterModel.set((prev) => {155 const next = { ...prev };156 delete next[column.id];157
158 return next;159 });160 popoverControls.openChange(false);161 }}162 >163 Clear164 </button>165 <button data-ln-button="primary" data-ln-size="sm" disabled={!canSubmit}>166 Apply Filters167 </button>168 </div>169 </div>170 </form>171 );172}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data as movieData, type MovieData } from "./data.js";3import type { GridFilter } from "./types.js";4
5const sleep = () => new Promise((res) => setTimeout(res, 200));6
7export async function Server(reqs: DataRequest[], filterModel: Record<string, GridFilter>) {8 // Simulate latency and server work.9 await sleep();10
11 const filters = Object.entries(filterModel);12
13 const data =14 filters.length === 015 ? movieData16 : movieData.filter((row) => {17 for (const [columnId, filter] of filters) {18 const value = row[columnId as keyof MovieData];19 if (!value) return false;20
21 if (columnId === "imdb_rating") {22 if (filter.kind !== "number") continue;23
24 const rating = value ? Math.round(Number.parseFloat(value.split("/")[0]) / 2) : "";25 const checkValue = rating as number;26
27 if (filter.operator === "equals" && checkValue !== filter.value) return false;28 if (filter.operator === "not_equals" && checkValue === filter.value) return false;29 if (filter.operator === "greater_than" && checkValue <= filter.value) return false;30 if (filter.operator === "greater_than_or_equal" && checkValue < filter.value) return false;31 if (filter.operator === "less_than" && checkValue >= filter.value) return false;32 if (filter.operator === "less_than_or_equal" && checkValue > filter.value) return false;33
34 continue;35 }36
37 if (columnId === "released_at") {38 const v = new Date(value);39 const filterV = new Date(filter.value as string);40
41 if (filter.operator === "before" && v >= filterV) return false;42 if (filter.operator === "after" && v <= filterV) return false;43 continue;44 }45
46 if (columnId === "genre" && filter.operator === "equals") {47 const genres = value48 .toLowerCase()49 .split(",")50 .map((x) => x.trim());51
52 if (genres.some((x) => x === String(filter.value).toLowerCase())) continue;53 return false;54 }55 if (columnId === "genre" && filter.operator === "not_equals") {56 const genres = value57 .toLowerCase()58 .split(",")59 .map((x) => x.trim());60
61 if (genres.every((x) => x !== String(filter.value).toLowerCase())) continue;62 return false;63 }64
65 if (filter.operator === "equals" && `${filter.value}`.toLowerCase() !== value.toLowerCase())66 return false;67 if (filter.operator === "not_equals" && `${filter.value}`.toLowerCase() === value.toLowerCase())68 return false;69
70 if (71 filter.operator === "contains" &&72 !value.toLowerCase().includes(`${filter.value}`.toLowerCase())73 )74 return false;75 if (76 filter.operator === "not_contains" &&77 value.toLowerCase().includes(`${filter.value}`.toLowerCase())78 ) {79 return false;80 }81 }82
83 return true;84 });85
86 const pages = reqs.map((c) => {87 return {88 asOfTime: Date.now(),89 data: data.slice(c.start, c.end).map((x) => {90 return { kind: "leaf", id: x.uniq_id, data: x };91 }),92 start: c.start,93 end: c.end,94 kind: "center",95 path: c.path,96 size: Math.min(data.length, c.end),97 } satisfies DataResponse;98 });99
100 return pages;101}1export type FilterStringOperator = "equals" | "not_equals" | "not_contains" | "contains";2
3export interface FilterString {4 readonly operator: FilterStringOperator;5 readonly value: string;6 readonly kind: "text";7}8
9export type FilterNumberOperator =10 | "equals"11 | "not_equals"12 | "less_than"13 | "greater_than"14 | "less_than_or_equal"15 | "greater_than_or_equal";16export interface FilterNumber {17 readonly operator: FilterNumberOperator;18 readonly value: number;19 readonly kind: "number";20}21
22export type FilterDateOperator = "before" | "after";23
24export interface FilterDate {25 readonly operator: FilterDateOperator;26 readonly value: string;27 readonly kind: "date";28}29
30export type GridFilter = FilterString | FilterDate | FilterNumber;For more guidance on filtering rows on the server, see the Server Row Filtering guide.
Next Steps
- Infinite Row Sorting: Infinitely load rows by sending a defined sort model to the server.
- Infinite Rows: Infinitely load rows as the user scrolls toward the bottom of the viewport.
- Server Row Filtering: Filter server-side rows using the server data source.
