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.
Returning Pinned Rows From the Server
LyteNyte Grid's server data source doesn't track viewport changes for pinned rows because pinned rows never move. They stay fixed at the top or bottom of the grid's viewport.
To supply pinned rows, the server should respond with a
DataResponsePinned
object on any viewport request, including the initial
load. For details on DataResponsePinned
, see
the Data Interface guide and
the API reference.
This example pins rows to the top or bottom of the grid. The server implementation in the example always responds with data for pinned rows along side the data for the scrollable rows. Pinned rows will always be leaf rows.
Row Pinning
"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";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 },{ 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 RowPinning() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,});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.HeaderCellkey={c.id}cell={c}className="flex h-full w-full items-center px-2 text-sm capitalize"/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsTop>{view.rows.top.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.RowsTop><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.RowsBottom>{view.rows.bottom.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.RowsBottom></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 {DataFetcherFn,DataRequest,DataResponse,} from "@1771technologies/lytenyte-pro/types";import type { MovieData } from "./data";import { data } from "./data";const sleep = () => new Promise((res) => setTimeout(res, 600));export async function Server(reqs: DataRequest[]): ReturnType<DataFetcherFn<MovieData>> {// Simulate latency and server work.await sleep();const top = data.slice(0, 2);const bottom = data.slice(2, 4);const final = data.slice(4);return [{kind: "top",asOfTime: Date.now(),data: top.map((x) => ({ kind: "leaf", id: x.uniq_id, data: x })),},{kind: "bottom",asOfTime: Date.now(),data: bottom.map((x) => ({ kind: "leaf", id: x.uniq_id, data: x })),},// Normal data row responses...reqs.map((c) => {return {asOfTime: Date.now(),data: final.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: final.length,} satisfies DataResponse;}),];}
Pinned Component Sections
When using pinned rows, the grid view state includes top
and bottom
properties.
Use these properties to access the layout of the pinned rows:
const view = grid.view.useValue();// access the top rowsview.rows.top;// access the bottom rowsview.rows.bottom;
To display pinned rows, use the Grid.RowsTop
and Grid.RowsBottom
components.
For example, to display the top rows:
<Grid.RowsTop>{view.rows.top.map((row) => {if (row.kind === "full-width") return null;return (<Grid.Row row={row} key={row.id}>{row.cells.map((c) => {return <Grid.Cell key={c.id} cell={c} />;})}</Grid.Row>);})}</Grid.RowsTop>
See the Row Pinning demo code above for a full example.
Pushing Pinned Row Updates
Each time the server data source requests more rows, the server can also
update the grid's pinned rows. Sometimes, pinned rows need updating even when the
viewport hasn't changed. In those cases, the server data source can push pinned rows
into the grid programmatically using the pushResponses
method:
const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests);},});// Push responsesds.pushResponses(...)
This method lets the client push response objects into the server data source as if they were returned from the server.
The example below shows its usage. The Pin One Top button pins a single row to the top, and the Remove Pinned button removes it. Both actions are triggered entirely from the client without any server response.
Pushing Pinned Rows
"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 { data, type MovieData } from "./data";import {GenreRenderer,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 },{ 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 RowPinningPushed() {const ds = useServerDataSource<MovieData>({dataFetcher: (params) => {return Server(params.requests);},blockSize: 50,});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,columns,});const view = grid.view.useValue();return (<><div className="border-b-ln-gray-20 flex gap-2 border-b px-2 py-4"><buttonclassName="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"onClick={() => {ds.pushResponses([{kind: "top",asOfTime: Date.now(),data: [{ id: data[0].uniq_id, kind: "leaf", data: data[0] }],},]);}}>Pin One Top</button><buttonclassName="bg-ln-gray-10 hover:bg-ln-gray-20 border-ln-gray-30 cursor-pointer rounded border px-2 text-sm"onClick={() => {ds.pushResponses([{kind: "top",asOfTime: Date.now(),data: [],},]);}}>Remove Pinned</button></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 h-full w-full items-center px-2 text-sm capitalize"/>);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainerclassName={ds.isLoading.useValue() ? "animate-pulse bg-gray-100" : ""}><Grid.RowsTop>{view.rows.top.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.RowsTop><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.RowsBottom>{view.rows.bottom.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.RowsBottom></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 {DataFetcherFn,DataRequest,DataResponse,} 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[]): ReturnType<DataFetcherFn<MovieData>> {// Simulate latency and server work.await sleep();const data = movieData.slice(1);return [// Normal data row responses...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;}),];}
Using the pushResponses
method is one way to push data into the grid.
For more information, see the Data Pushing and Pulling guide.
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 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.
Server Row Grouping
Display hierarchical data efficiently with LyteNyte Grid. Use the server data source to request and assemble data slices for grouped grid views.