Paginated Rows
Use the server data source to paginate rows using a cursor-based offset to load rows one page at a time.
Note
Pagination uses the server data source. This guide assumes you’re familiar with the concepts covered in the Server Row Overview guide.
Server-Side Row Pagination
Paginate rows by combining the server data source
with a page cursor. Set up pagination in three steps:
- Define a
pagevalue in React state, for example using theuseStatehook. - Include the
pagevalue in thequeryKeyproperty of the server data source. - Pass a
queryFncallback that loads rows for the currentpage.
When the page value changes, LyteNyte Grid calls queryFn to fetch the next page of rows.
The demo below shows basic pagination with caching, so previously loaded pages render without refetching when you return to them.
Row Pagination
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useServerDataSource, type DataResponse } from "@1771technologies/lytenyte-pro";4
5import { useRef, useState } from "react";6import { Server } from "./server.jsx";7import type { MovieData } from "./data.js";8import {9 GenreRenderer,10 LinkRenderer,11 NameCellRenderer,12 RatingRenderer,13 ReleasedRenderer,14 TypeRenderer,15} from "./components.jsx";16import { Pager } from "./pager.jsx";17
18export interface GridSpec {19 readonly data: MovieData;20}21
22const columns: Grid.Column<GridSpec>[] = [23 {24 id: "#",25 name: "",26 width: 30,27 field: "link",28 widthMin: 30,29 widthMax: 30,30 cellRenderer: LinkRenderer,31 },32 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },33 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },34 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },35 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },36 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },37];38
39const pageSize = 10;40
41const headerHeight = 40;42const rowHeight = 40;43
44export default function PaginationDemo() {45 const [page, setPage] = useState(1);46 const [count, setCount] = useState<number | null>(null);47
48 const responseCache = useRef<Record<number, DataResponse[]>>({});49
50 const ds = useServerDataSource<GridSpec["data"], [page: number]>({51 queryFn: async ({ requests, queryKey }) => {52 const page = queryKey[0];53
54 if (responseCache.current[page]) {55 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));56 }57
58 const result = await Server(requests, page - 1, pageSize);59 responseCache.current[page] = result.pages;60
61 setCount(result.count);62
63 return result.pages;64 },65 queryKey: [page],66 });67
68 const isLoading = ds.isLoading.useValue();69
70 return (71 <div>72 <div className="ln-grid" style={{ height: pageSize * rowHeight + headerHeight }}>73 <Grid74 rowSource={ds}75 columns={columns}76 headerHeight={headerHeight}77 rowHeight={rowHeight}78 slotViewportOverlay={79 isLoading && (80 <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>81 )82 }83 />84 </div>85 <div className="border-ln-border h-13 flex items-center justify-end gap-4 border-t px-4">86 {count && <Pager page={page} onPageChange={setPage} count={count} pageSize={pageSize} />}87 </div>88 </div>89 );90}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 React, { useEffect, useState } from "react";2import "@1771technologies/lytenyte-pro/components.css";3import { useMemo } from "react";4
5const formatter = Intl.NumberFormat("en-Us", { minimumFractionDigits: 0, maximumFractionDigits: 0 });6
7export function Pager({8 page,9 pageSize,10 count,11 onPageChange,12}: {13 pageSize: number;14 page: number;15 count: number;16 onPageChange: (page: number) => void;17}) {18 const pageCount = useMemo(() => {19 return Math.ceil(count / pageSize);20 }, [count, pageSize]);21
22 return (23 <div className="flex w-full items-center justify-between">24 <div className="ms-3 text-xs tabular-nums md:text-sm">25 Rows{" "}26 <span className="font-bold">27 {formatter.format((page - 1) * pageSize + 1)}-{formatter.format(page * pageSize)}28 </span>{" "}29 of <span className="font-bold">{formatter.format(count)}</span>30 </div>31
32 <div className="flex items-center gap-1">33 <button data-ln-button="secondary" data-ln-size="md" onClick={() => onPageChange(1)}>34 <span className="sr-only">To first page</span>35 <svg36 xmlns="http://www.w3.org/2000/svg"37 width="16"38 height="16"39 fill="currentcolor"40 viewBox="0 0 256 256"41 >42 <path d="M200,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,200,48ZM72,40a8,8,0,0,0-8,8V208a8,8,0,0,0,16,0V48A8,8,0,0,0,72,40Z"></path>43 </svg>44 </button>45 <button46 data-ln-button="secondary"47 data-ln-size="md"48 onClick={() => onPageChange(Math.max(1, page - 1))}49 >50 <span className="sr-only">Previous Page</span>51 <svg52 xmlns="http://www.w3.org/2000/svg"53 width="16"54 height="16"55 fill="currentcolor"56 viewBox="0 0 256 256"57 >58 <path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path>59 </svg>60 </button>61 <div className="hidden items-center gap-1 px-2 tabular-nums md:flex">62 Page <IntegerNumberInput page={page} onPageChange={onPageChange} pageCount={pageCount} />63 of <span className="font-bold">{pageCount}</span>64 </div>65
66 <button67 data-ln-button="secondary"68 data-ln-size="md"69 onClick={() => onPageChange(Math.min(page + 1, pageCount))}70 >71 <span className="sr-only">Next page</span>72 <svg73 xmlns="http://www.w3.org/2000/svg"74 width="16"75 height="16"76 fill="currentcolor"77 viewBox="0 0 256 256"78 >79 <path d="M181.66,133.66l-80,80A8,8,0,0,1,88,208V48a8,8,0,0,1,13.66-5.66l80,80A8,8,0,0,1,181.66,133.66Z"></path>80 </svg>81 </button>82 <button data-ln-button="secondary" data-ln-size="md" onClick={() => onPageChange(pageCount)}>83 <span className="sr-only">To last page</span>84 <svg85 xmlns="http://www.w3.org/2000/svg"86 width="16"87 height="16"88 fill="currentcolor"89 viewBox="0 0 256 256"90 >91 <path d="M149.66,122.34a8,8,0,0,1,0,11.32l-80,80A8,8,0,0,1,56,208V48a8,8,0,0,1,13.66-5.66ZM184,40a8,8,0,0,0-8,8V208a8,8,0,0,0,16,0V48A8,8,0,0,0,184,40Z"></path>92 </svg>93 </button>94 </div>95 </div>96 );97}98
99interface IntegerNumberInputProps {100 page: number;101 onPageChange: (value: number) => void;102 pageCount: number;103}104
105function IntegerNumberInput({ page, onPageChange, pageCount }: IntegerNumberInputProps) {106 const [inputValue, setInputValue] = useState<string>(String(page));107
108 // Keep internal state in sync if page prop changes externally109 useEffect(() => {110 setInputValue(String(page));111 }, [page]);112
113 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {114 const value = e.target.value;115
116 if (value === "") {117 setInputValue("");118 return;119 }120
121 if (/^\d+$/.test(value)) {122 const v = Math.max(Math.min(parseInt(value, 10), pageCount), 1);123 setInputValue(String(v));124 onPageChange(v);125 }126 };127
128 const handleBlur = () => {129 if (inputValue === "") {130 setInputValue(String(page));131 onPageChange(page);132 }133 };134
135 return (136 <input137 type="text"138 style={{ width: Math.max(`${inputValue}`.length, 3) * 12 + 18 }}139 className="text-center"140 data-ln-input141 inputMode="numeric"142 value={inputValue}143 onChange={handleChange}144 onBlur={handleBlur}145 />146 );147}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data } from "./data.js";3
4const sleep = () => new Promise((res) => setTimeout(res, 500));5
6export async function Server(reqs: DataRequest[], page: number, pageSize: number) {7 // Simulate latency and server work.8 await sleep();9
10 let pageStart = page * pageSize;11
12 if (pageStart > data.length) {13 pageStart = data.length - pageSize;14 }15
16 const pages = reqs.map((c) => {17 const pageData = data.slice(pageStart, pageStart + pageSize);18
19 return {20 asOfTime: Date.now(),21 data: pageData.map((x) => {22 return { kind: "leaf", id: x.uniq_id, data: x };23 }),24 start: c.start,25 end: c.end,26 kind: "center",27 path: c.path,28 size: Math.min(pageSize, pageData.length),29 } satisfies DataResponse;30 });31
32 return {33 pages,34 count: data.length,35 };36}The server returns both the current page’s data and the total row count, allowing you to calculate the total number of available pages.
1const responseCache = useRef<Record<number, DataResponse[]>>({});2
3const ds = useServerDataSource<GridSpec["data"], [page: number]>({4 queryFn: async ({ requests, queryKey }) => {5 const page = queryKey[0];6
7 if (responseCache.current[page]) {8 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));9 }10
11 const result = await Server(requests, page, pageSize);12 responseCache.current[page] = result.pages;13
14 setCount(result.count);15 return result.pages;16 },17 queryKey: [page],18});Pagination Row Size
Configure the rows per page by sending pageSize to the server with
the current page value. Including pageSize in the server data
source’s queryKey ensures that any change in page size resets the
grid view and triggers a new server fetch.
In the demo below, use the Row per page menu to change the page size. Changing the page size clears the response cache and resets the page number to one.
Rows Per Page
1import "@1771technologies/lytenyte-pro/components.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useServerDataSource, type DataResponse } from "@1771technologies/lytenyte-pro";4
5import { useMemo, useRef, useState } from "react";6import { Server } from "./server.jsx";7import type { MovieData } from "./data.js";8import {9 GenreRenderer,10 LinkRenderer,11 NameCellRenderer,12 RatingRenderer,13 ReleasedRenderer,14 TypeRenderer,15} from "./components.jsx";16import { Pager } from "./pager.jsx";17
18export interface GridSpec {19 readonly data: MovieData;20}21
22const columns: Grid.Column<GridSpec>[] = [23 {24 id: "#",25 name: "",26 width: 30,27 field: "link",28 widthMin: 30,29 widthMax: 30,30 cellRenderer: LinkRenderer,31 },32 { id: "name", name: "Title", width: 250, widthFlex: 1, cellRenderer: NameCellRenderer },33 { id: "released_at", name: "Released", width: 120, cellRenderer: ReleasedRenderer },34 { id: "genre", name: "Genre", cellRenderer: GenreRenderer },35 { id: "type", name: "Type", width: 120, cellRenderer: TypeRenderer },36 { id: "imdb_rating", name: "Rating", width: 120, cellRenderer: RatingRenderer },37];38
39const headerHeight = 40;40const rowHeight = 40;41
42export default function PaginationDemo() {43 const [pageSize, setPageSize] = useState<number | "All">(10);44 const [page, setPage] = useState(1);45 const [count, setCount] = useState<number | null>(null);46
47 const responseCache = useRef<Record<number, DataResponse[]>>({});48
49 const ds = useServerDataSource<GridSpec["data"], [page: number, pageSize: number | "All"]>({50 queryFn: async ({ requests, queryKey }) => {51 const page = queryKey[0];52
53 if (responseCache.current[page]) {54 return responseCache.current[page].map((x) => ({ ...x, asOfTime: Date.now() }));55 }56
57 const result = await Server(requests, page - 1, pageSize);58 responseCache.current[page] = result.pages;59
60 setCount(result.count);61
62 return result.pages;63 },64 queryKey: [page, pageSize],65 });66
67 const isLoading = ds.isLoading.useValue();68
69 return (70 <div>71 <div className="ln-grid" style={{ height: 10 * rowHeight + headerHeight }}>72 <Grid73 rowSource={ds}74 columns={columns}75 headerHeight={headerHeight}76 rowHeight={rowHeight}77 styles={useMemo(() => {78 if (pageSize === 10) return {};79 return { viewport: { style: { scrollbarGutter: "stable" } } };80 }, [pageSize])}81 slotViewportOverlay={82 isLoading && (83 <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>84 )85 }86 />87 </div>88 <div className="border-ln-border h-13 flex items-center justify-end gap-4 border-t px-4">89 {count && (90 <Pager91 page={page}92 count={count}93 onPageChange={setPage}94 onPageSizeChange={(s) => {95 setPageSize(s);96 setPage(1);97 responseCache.current = {};98 }}99 pageSize={pageSize}100 />101 )}102 </div>103 </div>104 );105}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 React, { useEffect, useState } from "react";2import "@1771technologies/lytenyte-pro/components.css";3import { useMemo } from "react";4import { Menu } from "@1771technologies/lytenyte-pro/components";5import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons";6
7const formatter = Intl.NumberFormat("en-Us", { minimumFractionDigits: 0, maximumFractionDigits: 0 });8
9export function Pager({10 page,11 pageSize,12 count,13 onPageChange,14 onPageSizeChange,15}: {16 pageSize: number | "All";17 page: number;18 count: number;19 onPageChange: (page: number) => void;20 onPageSizeChange: (size: number | "All") => void;21}) {22 const pageCount = useMemo(() => {23 if (pageSize === "All") return 1;24
25 return Math.ceil(count / pageSize);26 }, [count, pageSize]);27
28 const start = pageSize === "All" ? 1 : (page - 1) * pageSize + 1;29 const end = pageSize === "All" ? count : page * pageSize;30
31 return (32 <div className="flex w-full items-center justify-between">33 <div className="flex items-center gap-3">34 <span className="hidden md:block">Per Page</span>35 <Menu>36 <Menu.Trigger data-ln-button="tertiary" data-ln-size="lg" className="gap-2">37 {pageSize} <ChevronDownIcon className="size-4" />38 </Menu.Trigger>39 <Menu.Popover>40 <Menu.Container className="min-w-12">41 {[10, 20, 50, "All"].map((size) => {42 return (43 <Menu.Item44 key={size}45 className="flex items-center gap-2"46 onAction={() => {47 onPageSizeChange(size as number | "All");48 }}49 >50 <span>{size}</span>51 {size === pageSize && <CheckIcon className="text-ln-primary-50 size-4" />}52 </Menu.Item>53 );54 })}55 </Menu.Container>56 </Menu.Popover>57 </Menu>58 <div className="ms-3 text-xs tabular-nums md:text-sm">59 Rows{" "}60 <span className="font-bold">61 {formatter.format(start)}-{formatter.format(end)}62 </span>{" "}63 of <span className="font-bold">{formatter.format(count)}</span>64 </div>65 </div>66
67 <div className="flex items-center gap-1">68 <button data-ln-button="secondary" data-ln-size="md" onClick={() => onPageChange(1)}>69 <span className="sr-only">To first page</span>70 <svg71 xmlns="http://www.w3.org/2000/svg"72 width="16"73 height="16"74 fill="currentcolor"75 viewBox="0 0 256 256"76 >77 <path d="M200,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,200,48ZM72,40a8,8,0,0,0-8,8V208a8,8,0,0,0,16,0V48A8,8,0,0,0,72,40Z"></path>78 </svg>79 </button>80 <button81 data-ln-button="secondary"82 data-ln-size="md"83 onClick={() => onPageChange(Math.max(1, page - 1))}84 >85 <span className="sr-only">Previous Page</span>86 <svg87 xmlns="http://www.w3.org/2000/svg"88 width="16"89 height="16"90 fill="currentcolor"91 viewBox="0 0 256 256"92 >93 <path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path>94 </svg>95 </button>96 <div className="hidden items-center gap-1 px-2 tabular-nums md:flex">97 Page <IntegerNumberInput page={page} onPageChange={onPageChange} pageCount={pageCount} />98 of <span className="font-bold">{pageCount}</span>99 </div>100
101 <button102 data-ln-button="secondary"103 data-ln-size="md"104 onClick={() => onPageChange(Math.min(page + 1, pageCount))}105 >106 <span className="sr-only">Next page</span>107 <svg108 xmlns="http://www.w3.org/2000/svg"109 width="16"110 height="16"111 fill="currentcolor"112 viewBox="0 0 256 256"113 >114 <path d="M181.66,133.66l-80,80A8,8,0,0,1,88,208V48a8,8,0,0,1,13.66-5.66l80,80A8,8,0,0,1,181.66,133.66Z"></path>115 </svg>116 </button>117 <button data-ln-button="secondary" data-ln-size="md" onClick={() => onPageChange(pageCount)}>118 <span className="sr-only">To last page</span>119 <svg120 xmlns="http://www.w3.org/2000/svg"121 width="16"122 height="16"123 fill="currentcolor"124 viewBox="0 0 256 256"125 >126 <path d="M149.66,122.34a8,8,0,0,1,0,11.32l-80,80A8,8,0,0,1,56,208V48a8,8,0,0,1,13.66-5.66ZM184,40a8,8,0,0,0-8,8V208a8,8,0,0,0,16,0V48A8,8,0,0,0,184,40Z"></path>127 </svg>128 </button>129 </div>130 </div>131 );132}133
134interface IntegerNumberInputProps {135 page: number;136 onPageChange: (value: number) => void;137 pageCount: number;138}139
140function IntegerNumberInput({ page, onPageChange, pageCount }: IntegerNumberInputProps) {141 const [inputValue, setInputValue] = useState<string>(String(page));142
143 // Keep internal state in sync if page prop changes externally144 useEffect(() => {145 setInputValue(String(page));146 }, [page]);147
148 const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {149 const value = e.target.value;150
151 if (value === "") {152 setInputValue("");153 return;154 }155
156 if (/^\d+$/.test(value)) {157 const v = Math.max(Math.min(parseInt(value, 10), pageCount), 1);158 setInputValue(String(v));159 onPageChange(v);160 }161 };162
163 const handleBlur = () => {164 if (inputValue === "") {165 setInputValue(String(page));166 onPageChange(page);167 }168 };169
170 return (171 <input172 type="text"173 style={{ width: Math.max(`${inputValue}`.length, 3) * 12 + 18 }}174 className="text-center"175 data-ln-input176 inputMode="numeric"177 value={inputValue}178 onChange={handleChange}179 onBlur={handleBlur}180 />181 );182}1import type { DataRequest, DataResponse } from "@1771technologies/lytenyte-pro";2import { data } from "./data.js";3
4const sleep = () => new Promise((res) => setTimeout(res, 200));5
6export async function Server(reqs: DataRequest[], page: number, pageSize: number | "All") {7 // Simulate latency and server work.8 await sleep();9
10 const start = pageSize === "All" ? 0 : page * pageSize;11 const end = pageSize === "All" ? data.length : start + pageSize;12
13 const pages = reqs.map((c) => {14 const pageData = data.slice(start, end);15
16 return {17 asOfTime: Date.now(),18 data: pageData.map((x) => {19 return { kind: "leaf", id: x.uniq_id, data: x };20 }),21 start: c.start,22 end: c.end,23 kind: "center",24 path: c.path,25 size: Math.min(pageSize === "All" ? pageData.length : pageSize, pageData.length),26 } satisfies DataResponse;27 });28
29 return {30 pages,31 count: data.length,32 };33}Next Steps
- Paginated Row Sorting: Request sorted pages by sending a defined sort model to the server.
- Paginated Row Filtering: Filter rows on the server before returning page slices to the client.
- Data Interface: Request and response interface of the LyteNyte Grid server data source.
