Server Row Pinning
Pin rows to the top or bottom of the viewport to keep them visible. With the server data source, the server returns these in a pinned-row response.
Server Pinned Rows
The server data source does not track viewport changes for pinned rows because they remain fixed.
To supply pinned rows, return a DataResponsePinned object for every viewport request, including the
initial load. For details, see the Data Interface guide.
This example pins rows to the top and bottom of the grid. The server always returns both pinned and scrollable rows. Note that pinned rows are always leaf rows.
Server Row Pinning
1"use client";2import "@1771technologies/lytenyte-pro/light-dark.css";3import {4 Grid,5 useServerDataSource,6 type UseServerDataSourceParams,7} from "@1771technologies/lytenyte-pro";8
9import { useCallback, useMemo } from "react";10import { Server } from "./server.js";11import type { MovieData } from "./data";12import {13 GenreRenderer,14 LinkRenderer,15 NameCellRenderer,16 RatingRenderer,17 ReleasedRenderer,18 TypeRenderer,19} from "./components.js";20
21export interface GridSpec {22 readonly data: MovieData;23}24
25const columns: Grid.Column<GridSpec>[] = [26 {27 id: "#",28 name: "",29 width: 30,30 field: "link",31 widthMin: 30,32 widthMax: 30,33 cellRenderer: LinkRenderer,34 },35 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },36 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },37 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },38 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },39 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },40];41
42export default function ServerDataDemo() {43 const queryFn: UseServerDataSourceParams<GridSpec["data"], []>["queryFn"] = useCallback((params) => {44 return Server(params.requests);45 }, []);46
47 const ds = useServerDataSource<GridSpec["data"], []>({48 queryFn,49 queryKey: [],50 blockSize: 50,51 });52
53 const isLoading = ds.isLoading.useValue();54
55 return (56 <div className="ln-grid" style={{ height: 500 }}>57 <Grid58 rowSource={ds}59 columns={columns}60 styles={useMemo(() => {61 return { viewport: { style: { scrollbarGutter: "stable" } } };62 }, [])}63 slotViewportOverlay={64 isLoading && (65 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>66 )67 }68 />69 </div>70 );71}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";8
9function SkeletonLoading() {10 return (11 <div className="h-full w-full p-2">12 <div className="bg-ln-gray-20 h-full w-full animate-pulse rounded-xl"></div>13 </div>14 );15}16
17export const NameCellRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {18 if (params.row.loading && !params.row.data) return <SkeletonLoading />;19
20 const field = params.api.columnField(params.column, params.row) as string;21
22 return <div className="overflow-hidden text-ellipsis">{field}</div>;23};24
25export const ReleasedRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {26 if (params.row.loading && !params.row.data) return <SkeletonLoading />;27 const field = params.api.columnField(params.column, params.row) as string;28
29 const formatted = field ? format(field, "dd MMM yyyy") : "-";30
31 return <div>{formatted}</div>;32};33
34export const GenreRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {35 if (params.row.loading && !params.row.data) return <SkeletonLoading />;36 const field = params.api.columnField(params.column, params.row) as string;37
38 const splits = field ? field.split(",") : [];39
40 return (41 <div className="flex h-full w-full items-center gap-1">42 {splits.map((c) => {43 return (44 <div45 className="border-(--primary-200) text-(--primary-700) dark:text-(--primary-500) bg-(--primary-200)/20 rounded border p-1 px-2 text-xs"46 key={c}47 >48 {c}49 </div>50 );51 })}52 </div>53 );54};55
56const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {57 return (58 <svg59 xmlns="http://www.w3.org/2000/svg"60 width="20"61 height="20"62 fill="currentcolor"63 viewBox="0 0 256 256"64 {...props}65 >66 <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>67 </svg>68 );69};70
71const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {72 return (73 <svg74 xmlns="http://www.w3.org/2000/svg"75 width="20"76 height="20"77 fill="currentcolor"78 viewBox="0 0 256 256"79 {...props}80 >81 <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>82 </svg>83 );84};85
86export const TypeRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {87 if (params.row.loading && !params.row.data) return <SkeletonLoading />;88 const field = params.api.columnField(params.column, params.row) as string;89
90 const isMovie = field === "Movie";91 const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;92
93 return (94 <div className="flex h-full w-full items-center gap-2">95 <span className={isMovie ? "text-(--primary-500)" : "text-ln-primary-50"}>96 <Icon />97 </span>98 <span>{field}</span>99 </div>100 );101};102
103export const RatingRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {104 if (params.row.loading && !params.row.data) return <SkeletonLoading />;105 const field = params.api.columnField(params.column, params.row) as string;106 const rating = field ? Number.parseFloat(field.split("/")[0]) : null;107 if (rating == null || Number.isNaN(rating)) return "-";108
109 return (110 <div className="flex h-full w-full items-center">111 <Rating112 style={{ maxWidth: 100 }}113 halfFillMode="svg"114 value={Math.round(rating / 2)}115 itemStyles={{116 activeFillColor: "hsla(173, 78%, 34%, 1)",117 itemShapes: ThinRoundedStar,118 inactiveFillColor: "transparent",119 inactiveBoxBorderColor: "transparent",120 inactiveBoxColor: "transparent",121 inactiveStrokeColor: "transparent",122 }}123 readOnly124 />125 </div>126 );127};128
129export const LinkRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {130 if (params.row.loading && !params.row.data) return <SkeletonLoading />;131 const field = params.api.columnField(params.column, params.row) as string;132
133 return (134 <a href={field} className="text-(--primary-500)">135 <Link1Icon />136 </a>137 );138};1import type {2 DataRequest,3 DataResponse,4 DataResponsePinned,5} from "@1771technologies/lytenyte-pro";6import { data } from "./data.js";7
8const sleep = () => new Promise((res) => setTimeout(res, 600));9
10export async function Server(reqs: DataRequest[]): Promise<(DataResponse | DataResponsePinned)[]> {11 // Simulate latency and server work.12 await sleep();13
14 const top = data.slice(0, 2);15 const bottom = data.slice(2, 4);16 const final = data.slice(4);17
18 return [19 {20 kind: "top",21 asOfTime: Date.now(),22 data: top.map((x) => ({ kind: "leaf", id: x.uniq_id, data: x })),23 },24 {25 kind: "bottom",26 asOfTime: Date.now(),27 data: bottom.map((x) => ({ kind: "leaf", id: x.uniq_id, data: x })),28 },29
30 // Normal data row responses31 ...reqs.map((c) => {32 return {33 asOfTime: Date.now(),34 data: final.slice(c.start, c.end).map((x) => {35 return {36 kind: "leaf",37 id: x.uniq_id,38 data: x,39 };40 }),41 start: c.start,42 end: c.end,43 kind: "center",44 path: c.path,45 size: final.length,46 } satisfies DataResponse;47 }),48 ];49}Pushing Pinned Row Updates
The server can update pinned rows on any server data source request.
To update pinned rows without a viewport change, call the server data source’s pushResponses method:
1const ds = useServerDataSource<MovieData>({2 queryFn: (params) => {3 return Server(params.requests);4 },5 //...6});7
8// Push responses9ds.pushResponses(...)The pushResponses method lets the client push response objects into
the server data source as if the server returned them.
In the demo below, Pin One Top pins a single row to the top, and Remove Pinned removes the pinned row. Both actions run on the client without a server response.
Pushing Pinned Rows
1"use client";2
3import "@1771technologies/lytenyte-pro/components.css";4import "@1771technologies/lytenyte-pro/light-dark.css";5import {6 Grid,7 useServerDataSource,8 type UseServerDataSourceParams,9} from "@1771technologies/lytenyte-pro";10
11import { useCallback, useMemo } from "react";12import { Server } from "./server.js";13import { data, type MovieData } from "./data.js";14import {15 GenreRenderer,16 LinkRenderer,17 NameCellRenderer,18 RatingRenderer,19 ReleasedRenderer,20 TypeRenderer,21} from "./components.js";22
23export interface GridSpec {24 readonly data: MovieData;25}26
27const columns: Grid.Column<GridSpec>[] = [28 {29 id: "#",30 name: "",31 width: 30,32 field: "link",33 widthMin: 30,34 widthMax: 30,35 cellRenderer: LinkRenderer,36 },37 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },38 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },39 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },40 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },41 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },42];43
44export default function ServerDataDemo() {45 const queryFn: UseServerDataSourceParams<GridSpec["data"], []>["queryFn"] = useCallback((params) => {46 return Server(params.requests);47 }, []);48
49 const ds = useServerDataSource<GridSpec["data"], []>({50 queryFn,51 queryKey: [],52 blockSize: 50,53 });54
55 const isLoading = ds.isLoading.useValue();56
57 return (58 <>59 <div className="border-b-ln-gray-20 flex gap-2 border-b px-2 py-4">60 <button61 data-ln-button="website"62 data-ln-size="md"63 onClick={() => {64 ds.pushResponses([65 {66 kind: "top",67 asOfTime: Date.now(),68 data: [{ id: data[0].uniq_id, kind: "leaf", data: data[0] }],69 },70 ]);71 }}72 >73 Pin One Top74 </button>75 <button76 data-ln-button="website"77 data-ln-size="md"78 onClick={() => {79 ds.pushResponses([80 {81 kind: "top",82 asOfTime: Date.now(),83 data: [],84 },85 ]);86 }}87 >88 Remove Pinned89 </button>90 </div>91
92 <div className="ln-grid" style={{ height: 500 }}>93 <Grid94 rowSource={ds}95 columns={columns}96 styles={useMemo(() => {97 return { viewport: { style: { scrollbarGutter: "stable" } } };98 }, [])}99 slotViewportOverlay={100 isLoading && (101 <div className="bg-ln-gray-20/40 absolute left-0 top-0 z-20 h-full w-full animate-pulse"></div>102 )103 }104 />105 </div>106 </>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";8
9function SkeletonLoading() {10 return (11 <div className="h-full w-full p-2">12 <div className="bg-ln-gray-20 h-full w-full animate-pulse rounded-xl"></div>13 </div>14 );15}16
17export const NameCellRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {18 if (params.row.loading && !params.row.data) return <SkeletonLoading />;19
20 const field = params.api.columnField(params.column, params.row) as string;21
22 return <div className="overflow-hidden text-ellipsis">{field}</div>;23};24
25export const ReleasedRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {26 if (params.row.loading && !params.row.data) return <SkeletonLoading />;27 const field = params.api.columnField(params.column, params.row) as string;28
29 const formatted = field ? format(field, "dd MMM yyyy") : "-";30
31 return <div>{formatted}</div>;32};33
34export const GenreRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {35 if (params.row.loading && !params.row.data) return <SkeletonLoading />;36 const field = params.api.columnField(params.column, params.row) as string;37
38 const splits = field ? field.split(",") : [];39
40 return (41 <div className="flex h-full w-full items-center gap-1">42 {splits.map((c) => {43 return (44 <div45 className="border-(--primary-200) text-(--primary-700) dark:text-(--primary-500) bg-(--primary-200)/20 rounded border p-1 px-2 text-xs"46 key={c}47 >48 {c}49 </div>50 );51 })}52 </div>53 );54};55
56const FilmRealIcon = (props: JSX.IntrinsicElements["svg"]) => {57 return (58 <svg59 xmlns="http://www.w3.org/2000/svg"60 width="20"61 height="20"62 fill="currentcolor"63 viewBox="0 0 256 256"64 {...props}65 >66 <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>67 </svg>68 );69};70
71const MonitorPlayIcon = (props: JSX.IntrinsicElements["svg"]) => {72 return (73 <svg74 xmlns="http://www.w3.org/2000/svg"75 width="20"76 height="20"77 fill="currentcolor"78 viewBox="0 0 256 256"79 {...props}80 >81 <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>82 </svg>83 );84};85
86export const TypeRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {87 if (params.row.loading && !params.row.data) return <SkeletonLoading />;88 const field = params.api.columnField(params.column, params.row) as string;89
90 const isMovie = field === "Movie";91 const Icon = isMovie ? FilmRealIcon : MonitorPlayIcon;92
93 return (94 <div className="flex h-full w-full items-center gap-2">95 <span className={isMovie ? "text-(--primary-500)" : "text-ln-primary-50"}>96 <Icon />97 </span>98 <span>{field}</span>99 </div>100 );101};102
103export const RatingRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {104 if (params.row.loading && !params.row.data) return <SkeletonLoading />;105 const field = params.api.columnField(params.column, params.row) as string;106 const rating = field ? Number.parseFloat(field.split("/")[0]) : null;107 if (rating == null || Number.isNaN(rating)) return "-";108
109 return (110 <div className="flex h-full w-full items-center">111 <Rating112 style={{ maxWidth: 100 }}113 halfFillMode="svg"114 value={Math.round(rating / 2)}115 itemStyles={{116 activeFillColor: "hsla(173, 78%, 34%, 1)",117 itemShapes: ThinRoundedStar,118 inactiveFillColor: "transparent",119 inactiveBoxBorderColor: "transparent",120 inactiveBoxColor: "transparent",121 inactiveStrokeColor: "transparent",122 }}123 readOnly124 />125 </div>126 );127};128
129export const LinkRenderer = (params: Grid.T.CellRendererParams<GridSpec>) => {130 if (params.row.loading && !params.row.data) return <SkeletonLoading />;131 const field = params.api.columnField(params.column, params.row) as string;132
133 return (134 <a href={field} className="text-(--primary-500)">135 <Link1Icon />136 </a>137 );138};1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data as movieData } from "./data.js";3
4const sleep = () => new Promise((res) => setTimeout(res, 600));5
6export async function Server(reqs: DataRequest[]): Promise<DataResponse[]> {7 // Simulate latency and server work.8 await sleep();9
10 const data = movieData.slice(1);11
12 return [13 // Normal data row responses14 ...reqs.map((c) => {15 return {16 asOfTime: Date.now(),17 data: data.slice(c.start, c.end).map((x) => {18 return {19 kind: "leaf",20 id: x.uniq_id,21 data: x,22 };23 }),24 start: c.start,25 end: c.end,26 kind: "center",27 path: c.path,28 size: data.length,29 } satisfies DataResponse;30 }),31 ];32}1import type { ClassValue } from "clsx";2import clsx from "clsx";3import { twMerge } from "tailwind-merge";4
5export function tw(...c: ClassValue[]) {6 return twMerge(clsx(...c));7}pushResponses is one way to push data into the grid. For more
details, see the Data Pushing or Pulling guide.
Next Steps
- Server Row Grouping: Use the server data source to load and manage group slices.
- Server Row Sorting: Sort rows on the server using a defined sort model.
- Optimistic Loading: Pre-fetch data using optimistic loading to reduce perceived latency.
