Server Row Filtering
LyteNyte Grid defines several filter models that the server data source can use to filter rows on the server. The server may also define its own custom filter model to implement application-specific filtering logic.
Available Filter Models
There are three parts of the LyteNyte Filter Model. Additionally a custom filter model may be defined. These provide four different ways filters may be applied when using the LyteNyte Grid server data source:
- Filter Model: standard column filters that apply predicates to cell values, such as greater than or less than comparisons.
- Filter In Model: a tree-set definition used for inclusion or exclusion of values.
- Quick Search: a string-based search that matches a value across any cell in a row.
- Custom: a developer-defined model, not part of the LyteNyte Grid state. Developers can define any model structure that suits their application.
Handling the Filter Model on the Server
LyteNyte Grid's filterModel
state defines column filters that servers can use for basic filtering.
A server may use this to return only rows that meet a column condition, such as cells greater than a given value.
The filter logic shown below implements only a subset of LyteNyte Grid's full filter model for illustration purposes. Additional filters, such as the date filter's quarterly options have been omitted from the implementation for brevity. Most databases already support equivalent operators, so developers rarely need to reimplement complex filtering manually.
Column Filters
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { Server } from "./server";import type { MovieData } from "./data";import {GenreRenderer,HeaderRenderer,LinkRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";const columns: Column<MovieData>[] = [{id: "#",name: "",width: 30,field: "link",widthMin: 30,widthMax: 30,cellRenderer: LinkRenderer,},{ id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer, type: "date" },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer },{ id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function Filtering() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, params.model.filters);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,filterModel: {name: {kind: "string",operator: "contains",value: "Star",},},columnBase: {headerRenderer: HeaderRenderer,},});const view = grid.view.useValue();return (<div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return <Grid.HeaderCell key={c.id} cell={c} />;})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type {CellRendererFn,HeaderCellRendererFn,HeaderCellRendererParams,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import { useState, type JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { Link1Icon } from "@radix-ui/react-icons";import { GridInput, GridSelect, Popover, PopoverContent, PopoverTrigger, tw } from "./ui";import { FunnelIcon } from "lucide-react";import { FilterSelect } from "@1771technologies/lytenyte-pro";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export const LinkRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return (<a href={field}><Link1Icon /></a>);};export const HeaderRenderer: HeaderCellRendererFn<MovieData> = ({ grid, column }) => {const model = grid.state.filterModel.useValue();const filter = model[column.id];const [open, setOpen] = useState(false);if (column.id === "#") return null;return (<div className="flex h-full w-full items-center justify-between px-2 text-sm capitalize"><span>{column.name ?? column.id}</span><span className="flex items-center justify-center"><Popover modal open={open} onOpenChange={setOpen}><PopoverTriggerclassName={tw("h-[20px] w-[20px] cursor-pointer",filter ? "relative opacity-100" : "opacity-70",)}>{filter && (<div className="bg-ln-primary-50 absolute right-0 top-0 h-2 w-2 rounded-full" />)}<FunnelIcon className="size-3" width={16} height={16} /></PopoverTrigger><PopoverContent><FilterContent column={column} grid={grid} onAction={() => setOpen(false)} /></PopoverContent></Popover></span></div>);};const simplifiedFilterOptions = ["equals", "not_equals", "less_than", "greater_than", "contains"];function FilterContent({grid,column,onAction,}: HeaderCellRendererParams<MovieData> & { onAction: () => void }) {const root = FilterSelect.useFilterSelect({ grid, column, maxCount: 1 });let filterOptions = simplifiedFilterOptions;if (column.id === "imdb_rating")filterOptions = ["equals", "not_equals", "less_than", "greater_than"];if (column.id === "type") filterOptions = ["equals", "not_equals"];if (column.id === "genre") filterOptions = ["equals", "not_equals", "contains"];if (column.id === "released_at") filterOptions = ["before", "after"];return (<FilterSelect.Root root={root}>{root.filters.map((f, i) => {return (<FilterSelect.FilterRow filter={f} key={i}><div className="flex flex-col gap-1"><FilterSelect.OperatorSelectclassName="cursor-pointer"as={(p) => {// Allow a simplified set of options for our filters.const options = p.options.filter((c) => filterOptions.includes(c.value));// Small hack for date columns since we don't allow equals in our little demo.if (p.value?.label === "Equals" && column.id === "released_at")queueMicrotask(() => {p.onChange(options[0]);});return <GridSelect options={options} value={p.value} onChange={p.onChange} />;}}/><FilterSelect.ValueInputonKeyDown={(e) => {if (e.key === "Enter") {e.stopPropagation();root.apply();onAction();}}}as={(p) => {return (<GridInputvalue={p.value ?? ""}onChange={(e) => p.onValueChange(e.target.value)}disabled={p.disabled}type={p.isNumberInput ? "number" : column.type === "date" ? "date" : "text"}/>);}}/></div></FilterSelect.FilterRow>);})}<div className="my-2 flex justify-end gap-2"><FilterSelect.ClearonKeyDown={(e) => {e.stopPropagation();}}className="border-ln-gray-30 hover:bg-ln-gray-10 bg-ln-gray-00 text-ln-gray-70 cursor-pointer rounded border px-3 py-0.5 text-sm"onClick={onAction}/><FilterSelect.ApplyonKeyDown={(e) => {e.stopPropagation();}}onClick={onAction}style={{ transform: "scale(0.92)" }}className="border-ln-primary-30 hover:bg-ln-primary-70 bg-ln-primary-50 text-ln-gray-02 cursor-pointer rounded border px-3 py-0.5 text-sm font-semibold"/></div></FilterSelect.Root>);}
import type {DataRequest,DataResponse,FilterModelItem,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { data as movieData } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[],filterModel: Record<string, FilterModelItem<MovieData>>,) {// Simulate latency and server work.await sleep();const filters = Object.entries(filterModel);const data =filters.length === 0? movieData: movieData.filter((row) => {for (const [columnId, filter] of filters) {// Our logic here only handles a small subset of the possible filter functionality// for ease of implementation.if (filter.kind !== "string" && filter.kind !== "date") return false;const value = row[columnId as keyof MovieData];if (!value) return false;if (columnId === "imdb_rating") {const rating = value ? Math.round(Number.parseFloat(value.split("/")[0]) / 2) : "";const v = rating as number;const filterV =typeof filter.value === "string"? Number.parseInt(filter.value): (filter.value as number);if (filter.operator === "equals" && v !== filterV) return false;if (filter.operator === "not_equals" && v === filterV) return false;if (filter.operator === "less_than" && v >= filterV) return false;if (filter.operator === "greater_than" && v <= filterV) return false;continue;}if (columnId === "released_at") {const v = new Date(value);const filterV = new Date(filter.value as string);if (filter.operator === "before" && v >= filterV) return false;if (filter.operator === "after" && v <= filterV) return false;continue;}if ((filter.operator === "equals" || filter.operator === "not_equals") &&columnId === "genre") {const genres = value.split(",").map((x) => x.trim());if (filter.operator === "not_equals" && genres.every((x) => x === filter.value))return false;if (filter.operator === "equals" && genres.every((x) => x !== filter.value))return false;}if (columnId !== "genre" && filter.operator === "equals" && value !== filter.value)return false;if (columnId !== "genre" && filter.operator === "not_equals" && value === filter.value)return false;if (filter.operator === "less_than" && value >= filter.value!) return false;if (filter.operator === "greater_than" && value <= filter.value!) return false;if (filter.operator === "contains" &&!value.toLowerCase().includes(`${filter.value}`.toLowerCase()))return false;}return true;});return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
"use client";import { Popover as PopoverPrimitive, Select } from "radix-ui";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";import { forwardRef, useMemo, useState } from "react";import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons";const Popover = PopoverPrimitive.Root;const PopoverTrigger = PopoverPrimitive.Trigger;const PopoverContent = forwardRef<React.ComponentRef<typeof PopoverPrimitive.Content>,React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>>(({ align = "center", sideOffset = 4, ...props }, ref) => (<PopoverPrimitive.Portal><PopoverPrimitive.Contentref={ref}align={align}sideOffset={sideOffset}side="bottom"className={"bg-ln-gray-05 text-ln-gray-80 data-[state=closed]:animate-popover-out data-[state=open]:animate-popover-in origin-(--radix-popover-content-transform-origin) z-50 min-w-[240px] max-w-[98vw] rounded-xl border px-2 pt-2 text-sm shadow-lg backdrop-blur-lg focus-visible:outline-none"}{...props}/></PopoverPrimitive.Portal>));PopoverContent.displayName = PopoverPrimitive.Content.displayName;const PopoverClose = PopoverPrimitive.PopoverClose;export { Popover, PopoverTrigger, PopoverContent, PopoverClose };export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export function GridInput(props: React.JSX.IntrinsicElements["input"]) {return (<input{...props}className={tw("data-[placeholder]:text-ln-gray-70 flex h-[28px] min-w-full items-center justify-between rounded-lg px-2 text-sm shadow-[0_1.5px_2px_0px_var(--lng1771-gray-30),0_0_0_1px_var(--lng1771-gray-30)] md:min-w-[160px]","bg-ln-gray-00 text-ln-gray-90 gap-2","focus-visible:shadow-[0_1.5px_2px_0px_var(--lng1771-primary-50),0_0_0_1px_var(--lng1771-primary-50)] focus-visible:outline-none","data-[placeholder]:data-[disabled]:text-ln-gray-50 data-[disabled]:shadow-[0_1.5px_2px_0px_var(--lng1771-gray-20),0_0_0_1px_var(--lng1771-gray-20)]","disabled:text-ln-gray-50 disabled:shadow-[0_1.5px_2px_0px_var(--lng1771-gray-20),0_0_0_1px_var(--lng1771-gray-20)]",props.className,)}/>);}interface SelectOption {value: string;label: string;}interface GridSelectProps {readonly placeholder?: string;readonly value?: SelectOption | null;readonly onChange?: (v: SelectOption) => void;readonly options: SelectOption[];readonly className?: string;readonly disabled?: boolean;}export function GridSelect(p: GridSelectProps) {const value = useMemo(() => p.value?.value ?? "", [p.value]);const [open, setOpen] = useState(false);return (<Select.Rootvalue={value}open={open}onOpenChange={setOpen}onValueChange={(v) => {const value = p.options.find((c) => c.value === v)!;p.onChange?.(value);}}><Select.Triggerdisabled={p.disabled}className={tw("data-[placeholder]:text-ln-gray-70 flex h-[28px] min-w-full items-center justify-between rounded-lg px-2 text-sm shadow-[0_1.5px_2px_0px_var(--lng1771-gray-30),0_0_0_1px_var(--lng1771-gray-30)] md:min-w-[160px]","bg-ln-gray-00 text-ln-gray-90 gap-2","data-[placeholder]:data-[disabled]:text-ln-gray-50 data-[disabled]:shadow-[0_1.5px_2px_0px_var(--lng1771-gray-20),0_0_0_1px_var(--lng1771-gray-20)]","focus-visible:shadow-[0_1.5px_2px_0px_var(--lng1771-primary-50),0_0_0_1px_var(--lng1771-primary-50)]","overflow-hidden text-ellipsis whitespace-nowrap text-nowrap",p.className,)}><Select.Value placeholder={p.placeholder ?? "Select..."} /><Select.Icon><ChevronDownIcon /></Select.Icon></Select.Trigger><Select.Portal><Select.Contentposition="popper"sideOffset={5}className="border-ln-gray-30 bg-ln-gray-02 z-[100] max-h-[300px] min-w-[var(--radix-select-trigger-width)] overflow-y-auto overflow-x-hidden rounded-lg border shadow-[0_14px_18px_-6px_rgba(30,30,41,0.07),0_3px_13px_0_rgba(30,30,41,0.10)] md:max-h-[unset]"inert={false}><Select.ScrollUpButton className="flex h-[25px] cursor-default items-center justify-center"><ChevronUpIcon /></Select.ScrollUpButton><Select.Viewport className="p-[4px]">{p.options.map((c) => {return (<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>);})}</Select.Viewport><Select.ScrollDownButton className="flex h-[25px] cursor-default items-center justify-center"><ChevronDownIcon /></Select.ScrollDownButton></Select.Content></Select.Portal></Select.Root>);}const SelectItem = forwardRef(({ children, className, ...props }: Select.SelectItemProps, forwardedRef) => {return (<Select.ItemclassName={tw("text-ln-gray-80 h-[32px] px-2 py-1 text-sm","data-[disabled]:text-ln-gray-60 data-[disabled]:pointer-events-none","data-[highlighted]:text-lng-gray-90 data-[highlighted]:bg-ln-gray-20 rounded-lg data-[highlighted]:outline-none","relative flex cursor-pointer select-none items-center leading-none",className,)}{...props}ref={forwardedRef as any}><Select.ItemText>{children}</Select.ItemText><Select.ItemIndicator className="absolute right-0 inline-flex w-[25px] items-center justify-center"><CheckIcon /></Select.ItemIndicator></Select.Item>);},);SelectItem.displayName = "SelectITem";
Filter Options
Each column filter may include additional options. The server decides whether and how to handle them. For example, a string filter might look like this:
{kind: "string",operator: "contains",value: "Star",options: {trimWhitespace: true}}
Here, trimWhitespace
is true
. The server reads this flag and trims whitespace from the
filter value before evaluating it. LyteNyte Grid leaves filter interpretation entirely up to
your backend. The server may ignore options or apply them as needed.
The grid expects only that the response includes valid rows for the given query.
The Filter In Model
The filterInModel
allows inclusion and exclusion checks for cell values.
These are often called “set filters” or “in filters.” The server must handle two responsibilities:
- Provide the list of unique filter values available for each column.
- Filter rows in response to changes in the grid's
filterInModel
state.
Providing Filter Values
The server data source accepts a dataInFilterItemFetcher
callback, a function that
returns a Promise
of filter values for a given column.
LyteNyte Grid calls this function whenever in-filter values are requested for display.
This function receives parameters described by the
DataInFilterItemFetcherParams
interface:
interface DataInFilterItemFetcherParams<T> {readonly grid: Grid<T>;readonly column: Column<T>;readonly reqTime: number;}
grid
: reference to the grid state; you can query sort or group state as needed.column
: the column requesting filter items.reqTime
: a Unix timestamp for handling request collisions.
The fetcher should return an array (or Promise
) of FilterInFilterItem
objects:
export interface FilterInFilterItem {readonly id: string;readonly label: string;readonly value: unknown;readonly groupPath?: string[];}
Each filter item must have a unique id
for rendering.
label
: displays the filter text.value
: is used for membership tests and must be storable in aSet
.groupPath
: (optional) defines hierarchical groupings, such as dates or category trees.
Applying the In Filter
After fetching filter items, users can include or exclude values. The example below uses LyteNyte Grid's Filter Tree to display choices for the Genre column. Click the funnel icon to open the filter.
Each time the funnel opens, the grid shows a loading indicator. LyteNyte Grid doesn't cache filter item responses. If needed, cache the responses in your data fetching layer.
In Filter
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { Server, ServerInFilter } from "./server";import type { MovieData } from "./data";import {GenreRenderer,HeaderRenderer,LinkRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";const columns: Column<MovieData>[] = [{id: "#",name: "",width: 30,field: "link",widthMin: 30,widthMax: 30,cellRenderer: LinkRenderer,},{id: "name",name: "Title",width: 250,widthFlex: 1,cellRenderer: NameCellRenderer,headerRenderer: HeaderRenderer,},{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer, type: "date" },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer, headerRenderer: HeaderRenderer },{id: "type",name: "Type",width: 120,cellRenderer: TypeRenderer,headerRenderer: HeaderRenderer,},{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function Filtering() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, params.model.filtersIn);},dataInFilterItemFetcher: (params) => {return ServerInFilter(params.column);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,filterInModel: {genre: {kind: "in",operator: "not_in",value: new Set(["Drama", "Animation", "Anime"]),},},});const view = grid.view.useValue();return (<div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellclassName="flex items-center px-2 text-sm"key={c.id}cell={c}/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type {CellRendererFn,HeaderCellRendererFn,HeaderCellRendererParams,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import { useState, type JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { Link1Icon } from "@radix-ui/react-icons";import {Checkbox,GridInput,Popover,PopoverClose,PopoverContent,PopoverTrigger,tw,} from "./ui";import { FunnelIcon } from "lucide-react";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export const LinkRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return (<a href={field}><Link1Icon /></a>);};export const HeaderRenderer: HeaderCellRendererFn<MovieData> = ({ grid, column }) => {const model = grid.state.filterInModel.useValue();const filter = model[column.id];const [open, setOpen] = useState(false);if (column.id === "#") return null;return (<div className="flex h-full w-full items-center justify-between text-sm capitalize"><span>{column.name ?? column.id}</span><span className="flex items-center justify-center"><Popover modal open={open} onOpenChange={setOpen}><PopoverTriggerclassName={tw("h-[20px] w-[20px] cursor-pointer",filter ? "relative opacity-100" : "opacity-70",)}>{filter && (<div className="bg-ln-primary-50 absolute right-0 top-0 h-2 w-2 rounded-full" />)}<FunnelIcon className="size-3" width={16} height={16} /></PopoverTrigger><PopoverContent className="px-0"><InFilterPopoverContent grid={grid} column={column} /></PopoverContent></Popover></span></div>);};import { FilterTree as T } from "@1771technologies/lytenyte-pro";import { SearchIcon } from "@1771technologies/lytenyte-pro/icons";type TreeItem = ReturnType<typeof T.useFilterTree>["tree"][number];export function InFilterPopoverContent({ column, grid }: HeaderCellRendererParams<MovieData>) {const [query, setQuery] = useState("");const inFilter = T.useFilterTree({grid,column,treeItemHeight: 30,query: query,});return (<><div className="px-2"><div className="bg-ln-gray-02 border-ln-gray-20 z-50 w-full rounded-lg border"><div className="relative"><GridInputclassName="bg-ln-gray-02 border-b-ln-gray-20 focus-visible::outline-none w-full rounded-b-none border-b px-7 text-xs shadow-none outline-none focus-visible:shadow-none"value={query}onChange={(e) => setQuery(e.target.value)}placeholder="Search..."/><SearchIcon className="text-ln-gray-70 absolute left-1.5 top-[6px] size-4" /></div><T.Root{...inFilter.rootProps}loadingAs={() => {return (<div className="flex items-center justify-center px-2 py-2">Loading items...</div>);}}><T.PanelclassName=""style={{height: 250,overflowY: "auto",overflowX: "hidden",position: "relative",}}>{inFilter.tree.map((c) => {return (<RenderNode item={c} key={c.kind === "branch" ? c.branch.id : c.leaf.data.id} />);})}</T.Panel><div className="flex justify-end gap-2 py-2"><PopoverCloseonClick={() => {inFilter.reset();grid.state.filterInModel.set((prev) => {const next = { ...prev };delete next[column.id];return next;});}}className={tw("border-ln-gray-30 hover:bg-ln-gray-10 bg-ln-gray-00 text-ln-gray-70 cursor-pointer rounded border px-3 py-0.5 text-sm",)}>Clear</PopoverClose><PopoverCloseonClick={() => {const filter = inFilter.rootProps.filterIn;const isIn = filter.operator === "in";const items = inFilter.rootProps.getAllIds();if ((isIn && filter.value.isSubsetOf(items)) ||(!isIn && filter.value.size === 0)) {grid.state.filterInModel.set((prev) => {const next = { ...prev };delete next[column.id];return next;});return;}inFilter.apply();}}style={{ transform: "scale(0.92)" }}className={tw("border-ln-primary-30 hover:bg-ln-primary-70 bg-ln-primary-50 text-ln-gray-02 cursor-pointer rounded border px-3 py-0.5 text-sm font-semibold",)}>Apply</PopoverClose></div></T.Root></div></div></>);}function RenderNode({ item }: { item: TreeItem }) {if (item.kind === "leaf") {return (<T.Leafitem={item}className="hover:bg-ln-gray-20 text-ln-gray-80 focus-visible:bg-ln-primary-30 flex cursor-pointer items-center gap-2 rounded-lg px-2 text-xs"><T.Checkboxas={(({ checked, toggle }: any) => {return <Checkbox checked={checked} onToggle={() => toggle} />;}) as any}/><T.Label className="overflow-hidden text-ellipsis text-nowrap" /></T.Leaf>);}const values = [...item.children.values()];return (<T.Branchitem={item}label={<div style={{ display: "flex", gap: "2px" }}><T.Checkbox /><T.Label /></div>}>{values.map((c) => {return <RenderNode item={c} key={c.kind === "branch" ? c.branch.id : c.leaf.data.id} />;})}</T.Branch>);}
import type {Column,DataRequest,DataResponse,FilterIn,FilterInFilterItem,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { data as movieData } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], filterModel: Record<string, FilterIn>) {// Simulate latency and server work.await sleep();const filters = Object.entries(filterModel);const data =filters.length === 0? movieData: movieData.filter((row) => {for (const [columnId, filter] of filters) {const value = row[columnId as keyof MovieData];if (!value) return false;const filterSet = new Set([...filter.value].map((x) => `${x}`.toLowerCase()));if (columnId === "genre") {const parts = value.split(",").map((c) => c.trim().toLowerCase()).filter(Boolean);if (filter.operator === "not_in" && parts.some((x) => filterSet.has(x))) return false;if (filter.operator === "in" && !parts.some((x) => filterSet.has(x))) return false;} else {if (filter.operator === "not_in" && filterSet.has(`${value}`.toLowerCase()))return false;if (filter.operator === "in" && !filterSet.has(`${value}`.toLowerCase()))return false;}}return true;});return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}export async function ServerInFilter(column: Column<MovieData>): Promise<FilterInFilterItem[]> {// Simulate latency and server work.await sleep();const values = movieData.flatMap((c) => {if (column.id === "genre") return c[column.id].split(",");return c[column.id as keyof MovieData];}).map((x) => x.trim()).filter(Boolean);return [...new Set(values)].sort().map((x) => {return {id: x,label: x,value: x,} satisfies FilterInFilterItem;});}
"use client";import { Popover as PopoverPrimitive, Checkbox as C } from "radix-ui";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";import { forwardRef } from "react";import { CheckIcon } from "@radix-ui/react-icons";const Popover = PopoverPrimitive.Root;const PopoverTrigger = PopoverPrimitive.Trigger;const PopoverContent = forwardRef<React.ComponentRef<typeof PopoverPrimitive.Content>,React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>>(({ align = "center", sideOffset = 4, ...props }, ref) => (<PopoverPrimitive.Portal><PopoverPrimitive.Contentref={ref}align={align}sideOffset={sideOffset}side="bottom"{...props}className={tw("bg-ln-gray-05 text-ln-gray-80 data-[state=closed]:animate-popover-out data-[state=open]:animate-popover-in origin-(--radix-popover-content-transform-origin) z-50 min-w-[240px] max-w-[98vw] rounded-xl border px-2 pt-2 text-sm shadow-lg backdrop-blur-lg focus-visible:outline-none",props.className,)}/></PopoverPrimitive.Portal>));PopoverContent.displayName = PopoverPrimitive.Content.displayName;const PopoverClose = PopoverPrimitive.PopoverClose;export { Popover, PopoverTrigger, PopoverContent, PopoverClose };export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export function GridInput(props: React.JSX.IntrinsicElements["input"]) {return (<input{...props}className={tw("data-[placeholder]:text-ln-gray-70 flex h-[28px] min-w-full items-center justify-between rounded-lg px-2 text-sm shadow-[0_1.5px_2px_0px_var(--lng1771-gray-30),0_0_0_1px_var(--lng1771-gray-30)] md:min-w-[160px]","bg-ln-gray-00 text-ln-gray-90 gap-2","focus-visible:shadow-[0_1.5px_2px_0px_var(--lng1771-primary-50),0_0_0_1px_var(--lng1771-primary-50)] focus-visible:outline-none","data-[placeholder]:data-[disabled]:text-ln-gray-50 data-[disabled]:shadow-[0_1.5px_2px_0px_var(--lng1771-gray-20),0_0_0_1px_var(--lng1771-gray-20)]","disabled:text-ln-gray-50 disabled:shadow-[0_1.5px_2px_0px_var(--lng1771-gray-20),0_0_0_1px_var(--lng1771-gray-20)]",props.className,)}/>);}export function Checkbox({ children, ...props }: C.CheckboxProps) {return (<label className="text-md text-light flex items-center gap-2"><C.Root{...(props as any)}type="button"className={tw("h-4 w-4 rounded border border-gray-400",props.checked && "bg-ln-primary-50 border-ln-primary-70",props.className,)}><C.CheckboxIndicator className="flex items-center justify-center text-white"><CheckIcon /></C.CheckboxIndicator></C.Root>{children}</label>);}
The in-filter logic runs on the server, allowing full control over interpretation. For example, the model below excludes specific genres:
filterInModel: {genre: {kind: "in",operator: "not_in",value: new Set(["Drama", "Animation", "Anime"]),},}
This filter keeps rows whose genre
value is not "Drama"
, "Animation"
, or "Anime"
.
If a genre
cell contains multiple values (e.g., "Comedy, Documentary"
), the server splits the string and checks each value.
In the demo, filtering ignores case sensitivity, but this behavior can be made configurable.
You have full control over how to interpret filters based on your data model.
Quick Search
LyteNyte Grid's quickSearch
property provides a fast way to filter rows by string matching across all columns.
The grid can send this value to the server for server side search, though this approach is practical only for small datasets.
Large scale string matching is costly, even for optimized databases.
The demo below uses the search term "movie"
, which keeps only rows containing that string. Try typing "drama"
to test it.
Quick Search
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import { Server } from "./server";import type { MovieData } from "./data";import {GenreRenderer,LinkRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";import { GridInput } from "./ui";const columns: Column<MovieData>[] = [{id: "#",name: "",width: 30,field: "link",widthMin: 30,widthMax: 30,cellRenderer: LinkRenderer,},{ id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer, type: "date" },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer },{ id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function Filtering() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, params.model.quickSearch);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,quickSearch: "movie",});const view = grid.view.useValue();return (<><div className="border-b-ln-gray-20 flex items-center gap-1 border-b px-2 py-2.5">Quick Search Value:<GridInputclassName="flex-1"value={grid.state.quickSearch.useValue() ?? ""}onChange={(e) => {grid.state.quickSearch.set(e.target.value || null);}}/></div><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className="flex items-center px-2 text-sm"/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import type { CellRendererFn } from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import { type JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { Link1Icon } from "@radix-ui/react-icons";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export const LinkRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return (<a href={field}><Link1Icon /></a>);};
import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro/types";import { data as movieData } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], quickSearchValue: string | null) {// Simulate latency and server work.await sleep();const data = !quickSearchValue? movieData: movieData.filter((row) => {const values = [row.released_at, row.genre, row.name, row.type];const searchStr = values.join(" ").toLowerCase();return searchStr.includes(quickSearchValue.toLowerCase());});return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
"use client";import type { ClassValue } from "clsx";import clsx from "clsx";import { twMerge } from "tailwind-merge";export function tw(...c: ClassValue[]) {return twMerge(clsx(...c));}export function GridInput(props: React.JSX.IntrinsicElements["input"]) {return (<input{...props}className={tw("data-[placeholder]:text-ln-gray-70 flex h-[28px] min-w-full items-center justify-between rounded-lg px-2 text-sm shadow-[0_1.5px_2px_0px_var(--lng1771-gray-30),0_0_0_1px_var(--lng1771-gray-30)] md:min-w-[160px]","bg-ln-gray-00 text-ln-gray-90 gap-2","focus-visible:shadow-[0_1.5px_2px_0px_var(--lng1771-primary-50),0_0_0_1px_var(--lng1771-primary-50)] focus-visible:outline-none","data-[placeholder]:data-[disabled]:text-ln-gray-50 data-[disabled]:shadow-[0_1.5px_2px_0px_var(--lng1771-gray-20),0_0_0_1px_var(--lng1771-gray-20)]","disabled:text-ln-gray-50 disabled:shadow-[0_1.5px_2px_0px_var(--lng1771-gray-20),0_0_0_1px_var(--lng1771-gray-20)]",props.className,)}/>);}
The example's <input />
is not debounced for simplicity. In production, debounce
the search input to avoid redundant server requests. For a guide on debouncing
see this article.
External Filters
Depending on your application, you may need a custom filter model.
You can integrate external filters into the data-fetching pipeline by injecting them into the dataFetcher
function of
the server data source. This can be done using the dataFetchExternals
property on the server data source.
This property accepts an array of dependencies, and will invalidate the dataFetcher
function and refetch
data from the server whenever one of the dependencies changes.
The following demo uses the dataFetchExternals
property to toggle between Movies
and TV Shows
:
External Filter
"use client";import { Grid, useServerDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId, useState } from "react";import { Server } from "./server";import type { MovieData } from "./data";import {GenreRenderer,LinkRenderer,NameCellRenderer,RatingRenderer,ReleasedRenderer,TypeRenderer,} from "./components";import clsx from "clsx";const columns: Column<MovieData>[] = [{id: "#",name: "",width: 30,field: "link",widthMin: 30,widthMax: 30,cellRenderer: LinkRenderer,},{ id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },{ id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer, type: "date" },{ id: "genre", name: "Genre", cellRenderer: GenreRenderer },{ id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },{ id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },];export default function Filtering() {const [on, setOn] = useState(true);const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests, on);},dataFetchExternals: [on],blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,quickSearch: "movie",});const view = grid.view.useValue();return (<><div className="border-b-ln-gray-20 flex items-center gap-1 border-b px-2 py-2.5"><div className="border-ln-primary-30 text-ln-gray-90 relative flex items-center overflow-hidden rounded-lg border text-sm"><buttonclassName={clsx("flex h-8 cursor-pointer items-center px-2",!on && "bg-ln-primary-30",on && "hover:bg-ln-gray-20 rounded transition-colors",)}onClick={() => setOn(false)}>TV Shows</button><div className="bg-ln-gray-30 h-8 w-px" /><buttonclassName={clsx("flex h-8 cursor-pointer items-center px-2",!on && "hover:bg-ln-gray-20 rounded transition-colors",on && "bg-ln-primary-30",)}onClick={() => setOn(true)}>Movies</button></div></div><div className="lng-grid" style={{ height: 500 }}><Grid.Root grid={grid}><Grid.Viewport style={{ overflowY: "scroll" }}><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow key={i} headerRowIndex={i}>{row.map((c) => {if (c.kind === "group") return null;return (<Grid.HeaderCellkey={c.id}cell={c}className="flex items-center px-2 text-sm"/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return (<Grid.Cellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm"/>);})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></>);}
import type { CellRendererFn } from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { format } from "date-fns";import { type JSX } from "react";import { Rating, ThinRoundedStar } from "@smastrom/react-rating";import "@smastrom/react-rating/style.css";import { Link1Icon } from "@radix-ui/react-icons";function SkeletonLoading() {return (<div className="h-full w-full p-2"><div className="h-full w-full animate-pulse rounded-xl bg-gray-200 dark:bg-gray-100"></div></div>);}export const NameCellRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return <div className="overflow-hidden text-ellipsis">{field}</div>;};export const ReleasedRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const formatted = field ? format(field, "dd MMM yyyy") : "-";return <div>{formatted}</div>;};export const GenreRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const splits = field ? field.split(",") : [];return (<div className="flex h-full w-full items-center gap-1">{splits.map((c) => {return (<divclassName="border-primary-200 text-primary-700 dark:text-primary-500 bg-primary-200/20 rounded border p-1 px-2 text-xs"key={c}>{c}</div>);})}</div>);};const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {return (<svgxmlns="http://www.w3.org/2000/svg"width="20"height="20"fill="currentcolor"viewBox="0 0 256 256"{...props}><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></svg>);};export const TypeRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const isMovie = field === "Movie";const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;return (<div className="flex h-full w-full items-center gap-2"><span className={isMovie ? "text-primary-500" : "text-accent-500"}><Icon /></span><span>{field}</span></div>);};export const RatingRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;const rating = field ? Number.parseFloat(field.split("/")[0]) : null;if (rating == null || Number.isNaN(rating)) return "-";return (<div className="flex h-full w-full items-center"><Ratingstyle={{ maxWidth: 100 }}halfFillMode="svg"value={Math.round(rating / 2)}itemStyles={{activeFillColor: "hsla(173, 78%, 34%, 1)",itemShapes: ThinRoundedStar,inactiveFillColor: "transparent",inactiveBoxBorderColor: "transparent",inactiveBoxColor: "transparent",inactiveStrokeColor: "transparent",}}readOnly/></div>);};export const LinkRenderer: CellRendererFn<MovieData> = (params) => {if (params.row.loading) return <SkeletonLoading />;const field = params.grid.api.columnField(params.column, params.row) as string;return (<a href={field}><Link1Icon /></a>);};
import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro/types";import { data as movieData } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[], showMovies: boolean) {// Simulate latency and server work.await sleep();const data = showMovies? movieData.filter((row) => row.type === "Movie"): movieData.filter((row) => row.type === "TV Show");return reqs.map((c) => {return {asOfTime: Date.now(),data: data.slice(c.start, c.end).map((x) => {return {kind: "leaf",id: x.uniq_id,data: x,};}),start: c.start,end: c.end,kind: "center",path: c.path,size: data.length,} satisfies DataResponse;});}
This is one of many ways to use external filters. With the right state
management tools, you can build more advanced integrations. The key is ensuring your dataFetchExternals
dependencies are stable and only change when necessary.
Next Steps
- Server Row Grouping and Aggregations: learn how to fetch grouped data and handle row expansion and collapse.
- Server Row Sorting: implement and display server-side sorting.
- Optimistic Loading: use optimistic loading to pre-fetch data and improve responsiveness.
Server Row Sorting
LyteNyte Grid's server data source requests sorted rows by sending a defined sort model. The server applies the sort and returns the corresponding slice of sorted data.
Server Row Pinning
You can pin specific rows to the top or bottom of the grid's viewport. Pinned rows stay visible while the user scrolls. When using a server data source, the server manages pinned rows through a pinned row response.