Row Master Detail
Create expandable detail sections beneath rows using customizable React components, expansion controls, and programmatic API methods.
Enabling Row Detail
Row detail functionality in LyteNyte Grid is always enabled. Any row can display a detail section.
To create a detail section, define a rowDetailRenderer on the grid state object. LyteNyte Grid uses
this renderer to render the content of each row's detail area.
After defining a detail renderer, populate the rowDetailExpansions set on the grid state to control
which rows display their detail sections. rowDetailExpansions contains the row IDs of rows whose detail
areas are expanded.
The demo below shows this behavior. The marker column renders a detail toggle.
The toggle calls grid.api.rowDetailToggle, which adds or removes the row ID from the
rowDetailExpansions set.
Row Detail
"use client";import "./main.css";import { Grid, useClientRowDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import {DateCell,Header,LatencyCell,MethodCell,PathnameCell,RegionCell,RowDetailRenderer,StatusCell,TimingPhaseCell,} from "./components";import { type RequestData, requestData } from "./data";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";const columns: Column<RequestData>[] = [{id: "Date",name: "Date",width: 200,cellRenderer: DateCell,type: "datetime",},{ id: "Status", name: "Status", width: 100, cellRenderer: StatusCell },{id: "Method",name: "Method",width: 100,cellRenderer: MethodCell,},{ id: "timing-phase", name: "Timing Phase", cellRenderer: TimingPhaseCell },{ id: "Pathname", name: "Pathname", cellRenderer: PathnameCell },{id: "Latency",name: "Latency",width: 120,cellRenderer: LatencyCell,type: "number",},{ id: "region", name: "Region", cellRenderer: RegionCell },];export default function Demo() {const ds = useClientRowDataSource<RequestData>({data: requestData,});const grid = Grid.useLyteNyte({gridId: useId(),columns,rowDetailHeight: 200,rowDetailRenderer: RowDetailRenderer,columnMarkerEnabled: true,columnMarker: {width: 40,cellRenderer: ({ row, grid }) => {const isExpanded = grid.api.rowDetailIsExpanded(row);return (<buttonclassName="text-ln-gray-70 flex h-full w-[calc(100%-1px)] items-center justify-center pl-2"onClick={() => grid.api.rowDetailToggle(row)}>{isExpanded ? (<ChevronDownIcon width={20} height={20} />) : (<ChevronRightIcon width={20} height={20} />)}</button>);},},columnBase: {headerRenderer: Header,},rowDataSource: ds,});const view = grid.view.useValue();return (<div className="lng-grid" style={{ width: "100%", height: "400px" }}><Grid.Root grid={grid}><Grid.Viewport><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow headerRowIndex={i} key={i}>{row.map((c) => {if (c.kind === "group") return <Grid.HeaderGroupCell cell={c} key={c.idOccurrence} />;return <Grid.HeaderCell cell={c} key={c.column.id} className="after:bg-ln-gray-20" />;})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainer><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return <Grid.RowFullWidth row={row} key={row.id} />;return (<Grid.Row key={row.id} row={row} accepted={["row"]}>{row.cells.map((cell) => {return <Grid.Cell cell={cell} key={cell.id} />;})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type {CellRendererParams,HeaderCellRendererParams,RowDetailRendererParams,SortComparatorFn,SortModelItem,} from "@1771technologies/lytenyte-pro/types";import type { RequestData } from "./data";import { format } from "date-fns";import { useMemo } from "react";import clsx from "clsx";import { PieChart } from "react-minimal-pie-chart";import { ArrowDownIcon, ArrowUpIcon } from "@1771technologies/lytenyte-pro/icons";const colors = ["var(--transfer)", "var(--dns)", "var(--connection)", "var(--ttfb)", "var(--tls)"];const customComparators: Record<string, SortComparatorFn<RequestData>> = {region: (left, right) => {if (left.kind === "branch" || right.kind === "branch") {if (left.kind === "branch" && right.kind === "branch") return 0;if (left.kind === "branch" && right.kind !== "branch") return -1;if (left.kind !== "branch" && right.kind === "branch") return 1;}if (!left.data || !right.data) return !left.data ? 1 : -1;const leftData = left.data as RequestData;const rightData = right.data as RequestData;return leftData["region.fullname"].localeCompare(rightData["region.fullname"]);},"timing-phase": (left, right) => {if (left.kind === "branch" || right.kind === "branch") {if (left.kind === "branch" && right.kind === "branch") return 0;if (left.kind === "branch" && right.kind !== "branch") return -1;if (left.kind !== "branch" && right.kind === "branch") return 1;}if (!left.data || !right.data) return !left.data ? 1 : -1;const leftData = left.data as RequestData;const rightData = right.data as RequestData;return leftData.Latency - rightData.Latency;},};export function Header({ column, grid }: HeaderCellRendererParams<RequestData>) {const sort = grid.state.sortModel.useValue().find((c) => c.columnId === column.id);const isDescending = sort?.isDescending ?? false;return (<divclassName="text-ln-gray-60 flex h-full w-full items-center px-4 text-sm transition-all"onClick={() => {const current = grid.api.sortForColumn(column.id);if (current == null) {let sort: SortModelItem<RequestData>;const columnId = column.id;if (customComparators[column.id]) {sort = {columnId,sort: {kind: "custom",columnId,comparator: customComparators[column.id],},};} else if (column.type === "datetime") {sort = {columnId,sort: { kind: "date", options: { includeTime: true } },};} else if (column.type === "number") {sort = { columnId, sort: { kind: "number" } };} else {sort = { columnId, sort: { kind: "string" } };}grid.state.sortModel.set([sort]);return;}if (!current.sort.isDescending) {grid.state.sortModel.set([{ ...current.sort, isDescending: true }]);} else {grid.state.sortModel.set([]);}}}>{column.name ?? column.id}{sort && (<>{!isDescending ? <ArrowUpIcon className="size-4" /> : <ArrowDownIcon className="size-4" />}</>)}</div>);}export function DateCell({ column, row, grid }: CellRendererParams<RequestData>) {const field = grid.api.columnField(column, row);const niceDate = useMemo(() => {if (typeof field !== "string") return null;return format(field, "MMM dd, yyyy HH:mm:ss");}, [field]);// Guard against bad values and render nothingif (!niceDate) return null;return <div className="text-ln-gray-100 flex h-full w-full items-center px-4">{niceDate}</div>;}export function StatusCell({ column, row, grid }: CellRendererParams<RequestData>) {const status = grid.api.columnField(column, row);// Guard against bad valuesif (typeof status !== "number") return null;return (<div className={clsx("flex h-full w-full items-center px-4 text-xs font-bold")}><divclassName={clsx("rounded-sm px-1 py-px",status < 400 && "text-ln-primary-50 bg-[#126CFF1F]",status >= 400 && status < 500 && "bg-[#FF991D1C] text-[#EEA760]",status >= 500 && "bg-[#e63d3d2d] text-[#e63d3d]",)}>{status}</div></div>);}export function MethodCell({ column, row, grid }: CellRendererParams<RequestData>) {const method = grid.api.columnField(column, row);// Guard against bad valuesif (typeof method !== "string") return null;return (<div className={clsx("flex h-full w-full items-center px-4 text-xs font-bold")}><divclassName={clsx("rounded-sm px-1 py-px",method === "GET" && "text-ln-primary-50 bg-[#126CFF1F]",(method === "PATCH" || method === "PUT" || method === "POST") && "bg-[#FF991D1C] text-[#EEA760]",method === "DELETE" && "bg-[#e63d3d2d] text-[#e63d3d]",)}>{method}</div></div>);}export function PathnameCell({ column, row, grid }: CellRendererParams<RequestData>) {const path = grid.api.columnField(column, row);if (typeof path !== "string") return null;return (<div className="text-ln-gray-90 flex h-full w-full items-center px-4 text-sm"><div className="text-ln-primary-50 w-full overflow-hidden text-ellipsis text-nowrap">{path}</div></div>);}const numberFormatter = new Intl.NumberFormat("en-Us", {maximumFractionDigits: 0,minimumFractionDigits: 0,});export function LatencyCell({ column, row, grid }: CellRendererParams<RequestData>) {const ms = grid.api.columnField(column, row);if (typeof ms !== "number") return null;return (<div className="text-ln-gray-90 flex h-full w-full items-center px-4 text-sm tabular-nums"><div><span className="text-ln-gray-100">{numberFormatter.format(ms)}</span><span className="text-ln-gray-60 text-xs">ms</span></div></div>);}export function RegionCell({ grid, row }: CellRendererParams<RequestData>) {// Only render for leaf rows and we have some dataif (!grid.api.rowIsLeaf(row) || !row.data) return null;const shortName = row.data["region.shortname"];const longName = row.data["region.fullname"];return (<div className="flex h-full w-full items-center"><div className="flex items-baseline gap-2 px-4 text-sm"><div className="text-ln-gray-100">{shortName}</div><div className="text-ln-gray-60 leading-4">{longName}</div></div></div>);}export function TimingPhaseCell({ grid, row }: CellRendererParams<RequestData>) {// Guard against rows that are not leafs or rows that have no data.if (!grid.api.rowIsLeaf(row) || !row.data) return;const total =row.data["timing-phase.connection"] +row.data["timing-phase.dns"] +row.data["timing-phase.tls"] +row.data["timing-phase.transfer"] +row.data["timing-phase.ttfb"];const connectionPer = (row.data["timing-phase.connection"] / total) * 100;const dnsPer = (row.data["timing-phase.dns"] / total) * 100;const tlPer = (row.data["timing-phase.tls"] / total) * 100;const transferPer = (row.data["timing-phase.transfer"] / total) * 100;const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;const values = [connectionPer, dnsPer, tlPer, transferPer, ttfbPer];return (<div className="flex h-full w-full items-center px-4"><div className="flex h-4 w-full items-center gap-px overflow-hidden">{values.map((v, i) => {return (<divkey={i}style={{ width: `${v}%`, background: colors[i] }}className={clsx("h-full rounded-sm")}/>);})}</div></div>);}export function RowDetailRenderer({ row, grid }: RowDetailRendererParams<RequestData>) {// Guard against empty data.if (!grid.api.rowIsLeaf(row) || !row.data) return null;const total =row.data["timing-phase.connection"] +row.data["timing-phase.dns"] +row.data["timing-phase.tls"] +row.data["timing-phase.transfer"] +row.data["timing-phase.ttfb"];const connectionPer = (row.data["timing-phase.connection"] / total) * 100;const dnsPer = (row.data["timing-phase.dns"] / total) * 100;const tlPer = (row.data["timing-phase.tls"] / total) * 100;const transferPer = (row.data["timing-phase.transfer"] / total) * 100;const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;return (<div className="flex h-full flex-col px-4 pb-[20px] pt-[7px] text-sm"><h3 className="text-ln-gray-60 mt-0 text-xs font-[500]">Timing Phases</h3><div className="flex flex-1 gap-2 pt-[6px]"><div className="bg-ln-gray-00 border-ln-gray-20 h-full flex-1 rounded-[10px] border"><div className="grid-cols[auto_auto_1fr] grid grid-rows-5 gap-1 gap-x-4 p-4 md:grid-cols-[auto_auto_200px_auto]"><TimingPhaseRowlabel="Transfer"color={colors[0]}msPercentage={transferPer}msValue={row.data["timing-phase.transfer"]}/><TimingPhaseRowlabel="DNS"color={colors[1]}msPercentage={dnsPer}msValue={row.data["timing-phase.dns"]}/><TimingPhaseRowlabel="Connection"color={colors[2]}msPercentage={connectionPer}msValue={row.data["timing-phase.connection"]}/><TimingPhaseRowlabel="TTFB"color={colors[3]}msPercentage={ttfbPer}msValue={row.data["timing-phase.ttfb"]}/><TimingPhaseRowlabel="TLS"color={colors[4]}msPercentage={tlPer}msValue={row.data["timing-phase.tls"]}/><div className="col-start-3 row-span-full flex h-full flex-1 items-center justify-center"><TimingPhasePieChart row={row.data} /></div></div></div></div></div>);}interface TimePhaseRowProps {readonly color: string;readonly msValue: number;readonly msPercentage: number;readonly label: string;}function TimingPhaseRow({ color, msValue, msPercentage, label }: TimePhaseRowProps) {return (<><div className="text-sm">{label}</div><div className="text-sm tabular-nums">{msPercentage.toFixed(2)}%</div><div className="col-start-4 hidden items-center justify-end gap-1 text-sm md:flex"><div><span className="text-ln-gray-100">{numberFormatter.format(msValue)}</span><span className="text-ln-gray-60 text-xs">ms</span></div><divclassName="rounded"style={{width: `${msValue}px`,height: "12px",background: color,display: "block",}}></div></div></>);}function TimingPhasePieChart({ row }: { row: RequestData }) {const data = useMemo(() => {return [{ subject: "Transfer", value: row["timing-phase.transfer"], color: colors[0] },{ subject: "DNS", value: row["timing-phase.dns"], color: colors[1] },{ subject: "Connection", value: row["timing-phase.connection"], color: colors[2] },{ subject: "TTFB", value: row["timing-phase.ttfb"], color: colors[3] },{ subject: "TLS", value: row["timing-phase.tls"], color: colors[4] },];}, [row]);return (<div style={{ height: 100 }}><PieChart data={data} startAngle={180} lengthAngle={180} center={[50, 75]} paddingAngle={1} /></div>);}
Auto Row Detail Height
Use rowDetailHeight to control the height of the detail section. The rowDetailHeight property
accepts one of the following values:
- Number: A fixed height in pixels.
- Auto: The string value
"auto", which sizes the detail section based on its content.
By default, the grid uses a numeric height. Use "auto" when the detail content should determine
its own height.
Row Detail Height
"use client";import "./main.css";import { Grid, useClientRowDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import {DateCell,Header,LatencyCell,MethodCell,PathnameCell,RegionCell,RowDetailRenderer,StatusCell,TimingPhaseCell,} from "./components";import { type RequestData, requestData } from "./data";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";const columns: Column<RequestData>[] = [{id: "Date",name: "Date",width: 200,cellRenderer: DateCell,type: "datetime",},{ id: "Status", name: "Status", width: 100, cellRenderer: StatusCell },{id: "Method",name: "Method",width: 100,cellRenderer: MethodCell,},{ id: "timing-phase", name: "Timing Phase", cellRenderer: TimingPhaseCell },{ id: "Pathname", name: "Pathname", cellRenderer: PathnameCell },{id: "Latency",name: "Latency",width: 120,cellRenderer: LatencyCell,type: "number",},{ id: "region", name: "Region", cellRenderer: RegionCell },];export default function Demo() {const ds = useClientRowDataSource<RequestData>({data: requestData,});const grid = Grid.useLyteNyte({gridId: useId(),columns,rowDetailHeight: "auto",rowDetailRenderer: RowDetailRenderer,rowDetailExpansions: new Set(["0"]),columnMarkerEnabled: true,columnMarker: {width: 40,cellRenderer: ({ row, grid }) => {const isExpanded = grid.api.rowDetailIsExpanded(row);return (<buttonclassName="text-ln-gray-70 flex h-full w-[calc(100%-1px)] items-center justify-center pl-2"onClick={() => grid.api.rowDetailToggle(row)}>{isExpanded ? (<ChevronDownIcon width={20} height={20} />) : (<ChevronRightIcon width={20} height={20} />)}</button>);},},columnBase: {headerRenderer: Header,},rowDataSource: ds,});const view = grid.view.useValue();return (<div className="lng-grid" style={{ width: "100%", height: "400px" }}><Grid.Root grid={grid}><Grid.Viewport><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow headerRowIndex={i} key={i}>{row.map((c) => {if (c.kind === "group")return <Grid.HeaderGroupCell cell={c} key={c.idOccurrence} />;return (<Grid.HeaderCell cell={c} key={c.column.id} className="after:bg-ln-gray-20" />);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainer><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return <Grid.RowFullWidth row={row} key={row.id} />;return (<Grid.Row key={row.id} row={row} accepted={["row"]}>{row.cells.map((cell) => {return <Grid.Cell cell={cell} key={cell.id} />;})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type {CellRendererParams,HeaderCellRendererParams,RowDetailRendererParams,SortComparatorFn,SortModelItem,} from "@1771technologies/lytenyte-pro/types";import type { RequestData } from "./data";import { format } from "date-fns";import { useMemo } from "react";import clsx from "clsx";import { PieChart } from "react-minimal-pie-chart";import { ArrowDownIcon, ArrowUpIcon } from "@1771technologies/lytenyte-pro/icons";const colors = ["var(--transfer)", "var(--dns)", "var(--connection)", "var(--ttfb)", "var(--tls)"];const customComparators: Record<string, SortComparatorFn<RequestData>> = {region: (left, right) => {if (left.kind === "branch" || right.kind === "branch") {if (left.kind === "branch" && right.kind === "branch") return 0;if (left.kind === "branch" && right.kind !== "branch") return -1;if (left.kind !== "branch" && right.kind === "branch") return 1;}if (!left.data || !right.data) return !left.data ? 1 : -1;const leftData = left.data as RequestData;const rightData = right.data as RequestData;return leftData["region.fullname"].localeCompare(rightData["region.fullname"]);},"timing-phase": (left, right) => {if (left.kind === "branch" || right.kind === "branch") {if (left.kind === "branch" && right.kind === "branch") return 0;if (left.kind === "branch" && right.kind !== "branch") return -1;if (left.kind !== "branch" && right.kind === "branch") return 1;}if (!left.data || !right.data) return !left.data ? 1 : -1;const leftData = left.data as RequestData;const rightData = right.data as RequestData;return leftData.Latency - rightData.Latency;},};export function Header({ column, grid }: HeaderCellRendererParams<RequestData>) {const sort = grid.state.sortModel.useValue().find((c) => c.columnId === column.id);const isDescending = sort?.isDescending ?? false;return (<divclassName="text-ln-gray-60 flex h-full w-full items-center px-4 text-sm transition-all"onClick={() => {const current = grid.api.sortForColumn(column.id);if (current == null) {let sort: SortModelItem<RequestData>;const columnId = column.id;if (customComparators[column.id]) {sort = {columnId,sort: {kind: "custom",columnId,comparator: customComparators[column.id],},};} else if (column.type === "datetime") {sort = {columnId,sort: { kind: "date", options: { includeTime: true } },};} else if (column.type === "number") {sort = { columnId, sort: { kind: "number" } };} else {sort = { columnId, sort: { kind: "string" } };}grid.state.sortModel.set([sort]);return;}if (!current.sort.isDescending) {grid.state.sortModel.set([{ ...current.sort, isDescending: true }]);} else {grid.state.sortModel.set([]);}}}>{column.name ?? column.id}{sort && (<>{!isDescending ? (<ArrowUpIcon className="size-4" />) : (<ArrowDownIcon className="size-4" />)}</>)}</div>);}export function DateCell({ column, row, grid }: CellRendererParams<RequestData>) {const field = grid.api.columnField(column, row);const niceDate = useMemo(() => {if (typeof field !== "string") return null;return format(field, "MMM dd, yyyy HH:mm:ss");}, [field]);// Guard against bad values and render nothingif (!niceDate) return null;return <div className="text-ln-gray-100 flex h-full w-full items-center px-4">{niceDate}</div>;}export function StatusCell({ column, row, grid }: CellRendererParams<RequestData>) {const status = grid.api.columnField(column, row);// Guard against bad valuesif (typeof status !== "number") return null;return (<div className={clsx("flex h-full w-full items-center px-4 text-xs font-bold")}><divclassName={clsx("rounded-sm px-1 py-px",status < 400 && "text-ln-primary-50 bg-[#126CFF1F]",status >= 400 && status < 500 && "bg-[#FF991D1C] text-[#EEA760]",status >= 500 && "bg-[#e63d3d2d] text-[#e63d3d]",)}>{status}</div></div>);}export function MethodCell({ column, row, grid }: CellRendererParams<RequestData>) {const method = grid.api.columnField(column, row);// Guard against bad valuesif (typeof method !== "string") return null;return (<div className={clsx("flex h-full w-full items-center px-4 text-xs font-bold")}><divclassName={clsx("rounded-sm px-1 py-px",method === "GET" && "text-ln-primary-50 bg-[#126CFF1F]",(method === "PATCH" || method === "PUT" || method === "POST") &&"bg-[#FF991D1C] text-[#EEA760]",method === "DELETE" && "bg-[#e63d3d2d] text-[#e63d3d]",)}>{method}</div></div>);}export function PathnameCell({ column, row, grid }: CellRendererParams<RequestData>) {const path = grid.api.columnField(column, row);if (typeof path !== "string") return null;return (<div className="text-ln-gray-90 flex h-full w-full items-center px-4 text-sm"><div className="text-ln-primary-50 w-full overflow-hidden text-ellipsis text-nowrap">{path}</div></div>);}const numberFormatter = new Intl.NumberFormat("en-Us", {maximumFractionDigits: 0,minimumFractionDigits: 0,});export function LatencyCell({ column, row, grid }: CellRendererParams<RequestData>) {const ms = grid.api.columnField(column, row);if (typeof ms !== "number") return null;return (<div className="text-ln-gray-90 flex h-full w-full items-center px-4 text-sm tabular-nums"><div><span className="text-ln-gray-100">{numberFormatter.format(ms)}</span><span className="text-ln-gray-60 text-xs">ms</span></div></div>);}export function RegionCell({ grid, row }: CellRendererParams<RequestData>) {// Only render for leaf rows and we have some dataif (!grid.api.rowIsLeaf(row) || !row.data) return null;const shortName = row.data["region.shortname"];const longName = row.data["region.fullname"];return (<div className="flex h-full w-full items-center"><div className="flex items-baseline gap-2 px-4 text-sm"><div className="text-ln-gray-100">{shortName}</div><div className="text-ln-gray-60 leading-4">{longName}</div></div></div>);}export function TimingPhaseCell({ grid, row }: CellRendererParams<RequestData>) {// Guard against rows that are not leafs or rows that have no data.if (!grid.api.rowIsLeaf(row) || !row.data) return;const total =row.data["timing-phase.connection"] +row.data["timing-phase.dns"] +row.data["timing-phase.tls"] +row.data["timing-phase.transfer"] +row.data["timing-phase.ttfb"];const connectionPer = (row.data["timing-phase.connection"] / total) * 100;const dnsPer = (row.data["timing-phase.dns"] / total) * 100;const tlPer = (row.data["timing-phase.tls"] / total) * 100;const transferPer = (row.data["timing-phase.transfer"] / total) * 100;const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;const values = [connectionPer, dnsPer, tlPer, transferPer, ttfbPer];return (<div className="flex h-full w-full items-center px-4"><div className="flex h-4 w-full items-center gap-px overflow-hidden">{values.map((v, i) => {return (<divkey={i}style={{ width: `${v}%`, background: colors[i] }}className={clsx("h-full rounded-sm")}/>);})}</div></div>);}export function RowDetailRenderer({ row, grid }: RowDetailRendererParams<RequestData>) {// Guard against empty data.if (!grid.api.rowIsLeaf(row) || !row.data) return null;const total =row.data["timing-phase.connection"] +row.data["timing-phase.dns"] +row.data["timing-phase.tls"] +row.data["timing-phase.transfer"] +row.data["timing-phase.ttfb"];const connectionPer = (row.data["timing-phase.connection"] / total) * 100;const dnsPer = (row.data["timing-phase.dns"] / total) * 100;const tlPer = (row.data["timing-phase.tls"] / total) * 100;const transferPer = (row.data["timing-phase.transfer"] / total) * 100;const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;return (<div className="flex h-full flex-col px-4 py-8 text-sm"><h3 className="text-ln-gray-60 mt-0 text-xs font-[500]">Timing Phases</h3><div className="flex flex-1 gap-2 pt-[6px]"><div className="bg-ln-gray-00 border-ln-gray-20 h-full flex-1 rounded-[10px] border"><div className="grid-cols[auto_auto_1fr] grid grid-rows-5 gap-1 gap-x-4 p-4 md:grid-cols-[auto_auto_200px_auto]"><TimingPhaseRowlabel="Transfer"color={colors[0]}msPercentage={transferPer}msValue={row.data["timing-phase.transfer"]}/><TimingPhaseRowlabel="DNS"color={colors[1]}msPercentage={dnsPer}msValue={row.data["timing-phase.dns"]}/><TimingPhaseRowlabel="Connection"color={colors[2]}msPercentage={connectionPer}msValue={row.data["timing-phase.connection"]}/><TimingPhaseRowlabel="TTFB"color={colors[3]}msPercentage={ttfbPer}msValue={row.data["timing-phase.ttfb"]}/><TimingPhaseRowlabel="TLS"color={colors[4]}msPercentage={tlPer}msValue={row.data["timing-phase.tls"]}/><div className="col-start-3 row-span-full flex h-full flex-1 items-center justify-center"><TimingPhasePieChart row={row.data} /></div></div></div></div></div>);}interface TimePhaseRowProps {readonly color: string;readonly msValue: number;readonly msPercentage: number;readonly label: string;}function TimingPhaseRow({ color, msValue, msPercentage, label }: TimePhaseRowProps) {return (<><div className="text-sm">{label}</div><div className="text-sm tabular-nums">{msPercentage.toFixed(2)}%</div><div className="col-start-4 hidden items-center justify-end gap-1 text-sm md:flex"><div><span className="text-ln-gray-100">{numberFormatter.format(msValue)}</span><span className="text-ln-gray-60 text-xs">ms</span></div><divclassName="rounded"style={{width: `${msValue}px`,height: "12px",background: color,display: "block",}}></div></div></>);}function TimingPhasePieChart({ row }: { row: RequestData }) {const data = useMemo(() => {return [{ subject: "Transfer", value: row["timing-phase.transfer"], color: colors[0] },{ subject: "DNS", value: row["timing-phase.dns"], color: colors[1] },{ subject: "Connection", value: row["timing-phase.connection"], color: colors[2] },{ subject: "TTFB", value: row["timing-phase.ttfb"], color: colors[3] },{ subject: "TLS", value: row["timing-phase.tls"], color: colors[4] },];}, [row]);return (<div style={{ height: 100 }}><PieChart data={data} startAngle={180} lengthAngle={180} center={[50, 75]} paddingAngle={1} /></div>);}
Nested Grids
A common use case for row detail is rendering nested grids or tables. In this pattern, a master grid expands to reveal a child grid within the detail area. LyteNyte Grid supports this pattern by allowing a grid to be rendered inside a row detail section.
The demo below shows a nested grid rendered inside the detail area of a row:
Nested Grid Row Detail
"use client";import "./main.css";import { Grid, useClientRowDataSource } from "@1771technologies/lytenyte-pro";import "@1771technologies/lytenyte-pro/grid.css";import type { Column } from "@1771technologies/lytenyte-pro/types";import { useId } from "react";import {DateCell,Header,LatencyCell,MethodCell,PathnameCell,RegionCell,RowDetailRenderer,StatusCell,TimingPhaseCell,} from "./components";import { type RequestData, requestData } from "./data";import { ChevronDownIcon, ChevronRightIcon } from "@1771technologies/lytenyte-pro/icons";const columns: Column<RequestData>[] = [{id: "Date",name: "Date",width: 200,cellRenderer: DateCell,type: "datetime",},{ id: "Status", name: "Status", width: 100, cellRenderer: StatusCell },{id: "Method",name: "Method",width: 100,cellRenderer: MethodCell,},{ id: "timing-phase", name: "Timing Phase", cellRenderer: TimingPhaseCell },{ id: "Pathname", name: "Pathname", cellRenderer: PathnameCell },{id: "Latency",name: "Latency",width: 120,cellRenderer: LatencyCell,type: "number",},{ id: "region", name: "Region", cellRenderer: RegionCell },];export default function Demo() {const ds = useClientRowDataSource<RequestData>({data: requestData,});const grid = Grid.useLyteNyte({gridId: useId(),columns,rowDetailHeight: "auto",rowDetailRenderer: RowDetailRenderer,rowDetailExpansions: new Set(["0"]),columnMarkerEnabled: true,columnMarker: {width: 40,cellRenderer: ({ row, grid }) => {const isExpanded = grid.api.rowDetailIsExpanded(row);return (<buttonclassName="text-ln-gray-70 flex h-full w-[calc(100%-1px)] items-center justify-center pl-2"onClick={() => grid.api.rowDetailToggle(row)}>{isExpanded ? (<ChevronDownIcon width={20} height={20} />) : (<ChevronRightIcon width={20} height={20} />)}</button>);},},columnBase: {headerRenderer: Header,},rowDataSource: ds,});const view = grid.view.useValue();return (<div className="lng-grid" style={{ width: "100%", height: "400px" }}><Grid.Root grid={grid}><Grid.Viewport><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow headerRowIndex={i} key={i}>{row.map((c) => {if (c.kind === "group")return <Grid.HeaderGroupCell cell={c} key={c.idOccurrence} />;return (<Grid.HeaderCell cell={c} key={c.column.id} className="after:bg-ln-gray-20" />);})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainer><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return <Grid.RowFullWidth row={row} key={row.id} />;return (<Grid.Row key={row.id} row={row} accepted={["row"]}>{row.cells.map((cell) => {return <Grid.Cell cell={cell} key={cell.id} />;})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div>);}
import type {CellRendererParams,HeaderCellRendererParams,RowDetailRendererParams,SortComparatorFn,SortModelItem,} from "@1771technologies/lytenyte-pro/types";import type { RequestData } from "./data";import { format } from "date-fns";import { useId, useMemo } from "react";import clsx from "clsx";import { ArrowDownIcon, ArrowUpIcon } from "@1771technologies/lytenyte-pro/icons";import { Grid, useClientRowDataSource } from "@1771technologies/lytenyte-pro";const colors = ["var(--transfer)", "var(--dns)", "var(--connection)", "var(--ttfb)", "var(--tls)"];const customComparators: Record<string, SortComparatorFn<RequestData>> = {region: (left, right) => {if (left.kind === "branch" || right.kind === "branch") {if (left.kind === "branch" && right.kind === "branch") return 0;if (left.kind === "branch" && right.kind !== "branch") return -1;if (left.kind !== "branch" && right.kind === "branch") return 1;}if (!left.data || !right.data) return !left.data ? 1 : -1;const leftData = left.data as RequestData;const rightData = right.data as RequestData;return leftData["region.fullname"].localeCompare(rightData["region.fullname"]);},"timing-phase": (left, right) => {if (left.kind === "branch" || right.kind === "branch") {if (left.kind === "branch" && right.kind === "branch") return 0;if (left.kind === "branch" && right.kind !== "branch") return -1;if (left.kind !== "branch" && right.kind === "branch") return 1;}if (!left.data || !right.data) return !left.data ? 1 : -1;const leftData = left.data as RequestData;const rightData = right.data as RequestData;return leftData.Latency - rightData.Latency;},};export function Header({column,grid,noSort,}: HeaderCellRendererParams<RequestData> & { noSort?: boolean }) {const sort = grid.state.sortModel.useValue().find((c) => c.columnId === column.id);const isDescending = sort?.isDescending ?? false;return (<divclassName="text-ln-gray-60 flex h-full w-full items-center px-4 text-sm transition-all"onClick={() => {if (noSort) return;const current = grid.api.sortForColumn(column.id);if (current == null) {let sort: SortModelItem<RequestData>;const columnId = column.id;if (customComparators[column.id]) {sort = {columnId,sort: {kind: "custom",columnId,comparator: customComparators[column.id],},};} else if (column.type === "datetime") {sort = {columnId,sort: { kind: "date", options: { includeTime: true } },};} else if (column.type === "number") {sort = { columnId, sort: { kind: "number" } };} else {sort = { columnId, sort: { kind: "string" } };}grid.state.sortModel.set([sort]);return;}if (!current.sort.isDescending) {grid.state.sortModel.set([{ ...current.sort, isDescending: true }]);} else {grid.state.sortModel.set([]);}}}>{column.name ?? column.id}{sort && (<>{!isDescending ? <ArrowUpIcon className="size-4" /> : <ArrowDownIcon className="size-4" />}</>)}</div>);}export function DateCell({ column, row, grid }: CellRendererParams<RequestData>) {const field = grid.api.columnField(column, row);const niceDate = useMemo(() => {if (typeof field !== "string") return null;return format(field, "MMM dd, yyyy HH:mm:ss");}, [field]);// Guard against bad values and render nothingif (!niceDate) return null;return <div className="text-ln-gray-100 flex h-full w-full items-center px-4">{niceDate}</div>;}export function StatusCell({ column, row, grid }: CellRendererParams<RequestData>) {const status = grid.api.columnField(column, row);// Guard against bad valuesif (typeof status !== "number") return null;return (<div className={clsx("flex h-full w-full items-center px-4 text-xs font-bold")}><divclassName={clsx("rounded-sm px-1 py-px",status < 400 && "text-ln-primary-50 bg-[#126CFF1F]",status >= 400 && status < 500 && "bg-[#FF991D1C] text-[#EEA760]",status >= 500 && "bg-[#e63d3d2d] text-[#e63d3d]",)}>{status}</div></div>);}export function MethodCell({ column, row, grid }: CellRendererParams<RequestData>) {const method = grid.api.columnField(column, row);// Guard against bad valuesif (typeof method !== "string") return null;return (<div className={clsx("flex h-full w-full items-center px-4 text-xs font-bold")}><divclassName={clsx("rounded-sm px-1 py-px",method === "GET" && "text-ln-primary-50 bg-[#126CFF1F]",(method === "PATCH" || method === "PUT" || method === "POST") && "bg-[#FF991D1C] text-[#EEA760]",method === "DELETE" && "bg-[#e63d3d2d] text-[#e63d3d]",)}>{method}</div></div>);}export function PathnameCell({ column, row, grid }: CellRendererParams<RequestData>) {const path = grid.api.columnField(column, row);if (typeof path !== "string") return null;return (<div className="text-ln-gray-90 flex h-full w-full items-center px-4 text-sm"><div className="text-ln-primary-50 w-full overflow-hidden text-ellipsis text-nowrap">{path}</div></div>);}const numberFormatter = new Intl.NumberFormat("en-Us", {maximumFractionDigits: 0,minimumFractionDigits: 0,});export function LatencyCell({ column, row, grid }: CellRendererParams<RequestData>) {const ms = grid.api.columnField(column, row);if (typeof ms !== "number") return null;return (<div className="text-ln-gray-90 flex h-full w-full items-center px-4 text-sm tabular-nums"><div><span className="text-ln-gray-100">{numberFormatter.format(ms)}</span><span className="text-ln-gray-60 text-xs">ms</span></div></div>);}export function RegionCell({ grid, row }: CellRendererParams<RequestData>) {// Only render for leaf rows and we have some dataif (!grid.api.rowIsLeaf(row) || !row.data) return null;const shortName = row.data["region.shortname"];const longName = row.data["region.fullname"];return (<div className="flex h-full w-full items-center"><div className="flex items-baseline gap-2 px-4 text-sm"><div className="text-ln-gray-100">{shortName}</div><div className="text-ln-gray-60 leading-4">{longName}</div></div></div>);}export function TimingPhaseCell({ grid, row }: CellRendererParams<RequestData>) {// Guard against rows that are not leafs or rows that have no data.if (!grid.api.rowIsLeaf(row) || !row.data) return;const total =row.data["timing-phase.connection"] +row.data["timing-phase.dns"] +row.data["timing-phase.tls"] +row.data["timing-phase.transfer"] +row.data["timing-phase.ttfb"];const connectionPer = (row.data["timing-phase.connection"] / total) * 100;const dnsPer = (row.data["timing-phase.dns"] / total) * 100;const tlPer = (row.data["timing-phase.tls"] / total) * 100;const transferPer = (row.data["timing-phase.transfer"] / total) * 100;const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;const values = [connectionPer, dnsPer, tlPer, transferPer, ttfbPer];return (<div className="flex h-full w-full items-center px-4"><div className="flex h-4 w-full items-center gap-px overflow-hidden">{values.map((v, i) => {return (<divkey={i}style={{ width: `${v}%`, background: colors[i] }}className={clsx("h-full rounded-sm")}/>);})}</div></div>);}export function RowDetailRenderer({ row }: RowDetailRendererParams<RequestData>) {const data = row.data! as RequestData;const total =data["timing-phase.connection"] +data["timing-phase.dns"] +data["timing-phase.tls"] +data["timing-phase.transfer"] +data["timing-phase.ttfb"];const connectionPer = (data["timing-phase.connection"] / total) * 100;const dnsPer = (data["timing-phase.dns"] / total) * 100;const tlPer = (data["timing-phase.tls"] / total) * 100;const transferPer = (data["timing-phase.transfer"] / total) * 100;const ttfbPer = (data["timing-phase.ttfb"] / total) * 100;const ds = useClientRowDataSource({data: [{connectionPer,dnsPer,tlPer,transferPer,ttfbPer,},],});const grid = Grid.useLyteNyte({gridId: useId(),rowDataSource: ds,rowHeight: "fill:24",columnBase: {// Reuse header for convenienceheaderRenderer: (p) => <Header {...(p as any)} noSort />,cellRenderer: (p) => {const field = p.grid.api.columnField(p.column, p.row) as number;return (<div className="text-ln-gray-100 flex h-full w-full items-center px-3 tabular-nums">{field.toFixed(2)}%</div>);},},columns: [{ id: "connectionPer", name: "Connection %" },{ id: "dnsPer", name: "DNS %" },{ id: "tlPer", name: "TL %" },{ id: "transferPer", name: "Transfer %" },{ id: "ttfbPer", name: "TTFB %" },],});const view = grid.view.useValue();return (<div className="h-[160px] w-full px-8 py-8"><div className="h-full w-full border"><Grid.Root grid={grid}><Grid.Viewport><Grid.Header>{view.header.layout.map((row, i) => {return (<Grid.HeaderRow headerRowIndex={i} key={i}>{row.map((c) => {if (c.kind === "group") return <Grid.HeaderGroupCell cell={c} key={c.idOccurrence} />;return <Grid.HeaderCell cell={c} key={c.column.id} className="after:bg-ln-gray-20" />;})}</Grid.HeaderRow>);})}</Grid.Header><Grid.RowsContainer><Grid.RowsCenter>{view.rows.center.map((row) => {if (row.kind === "full-width") return <Grid.RowFullWidth row={row} key={row.id} />;return (<Grid.Row key={row.id} row={row} accepted={["row"]}>{row.cells.map((cell) => {return <Grid.Cell cell={cell} key={cell.id} />;})}</Grid.Row>);})}</Grid.RowsCenter></Grid.RowsContainer></Grid.Viewport></Grid.Root></div></div>);}
A nested grid is not a special case. To create one, have the detail renderer return another grid instance. LyteNyte Grid places no restrictions on what the detail renderer can display.
Next Steps
- Row Height: Change row height, including variable-height and fill-height rows.
- Row Pinning: Freeze rows at the top or bottom of the viewport.
- Row Selection: Select single or multiple rows.
- Row Full Width: Create rows that span the full width of the viewport.