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.
The demo shows row detail in action. The marker column renders a detail
toggle that calls api.rowDetailToggle,
which adds or removes the row ID from the rowDetailExpansions
set to control which rows display an expanded detail area.
Row Detail
24 collapsed lines
1import "./main.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import {4 Grid,5 useClientDataSource,6 type UseClientDataSourceParams,7} from "@1771technologies/lytenyte-pro";8
9import type { RequestData } from "./data.js";10import { requestData } from "./data.js";11import {12 DateCell,13 Header,14 LatencyCell,15 MarkerCell,16 MethodCell,17 PathnameCell,18 RegionCell,19 RowDetailRenderer,20 StatusCell,21 TimingPhaseCell,22} from "./components.js";23import { useMemo, useState } from "react";24import { sortComparators } from "./comparators.js";25
26export interface GridSpec {27 data: RequestData;28 column: { sort?: "asc" | "desc" | null };29}30
31const base: Grid.Props<GridSpec>["columnBase"] = {32 headerRenderer: Header,33};34
35const marker: Grid.Props<GridSpec>["columnMarker"] = {36 on: true,37 width: 40,38 headerRenderer: () => <div className="sr-only">Toggle row detail expansion</div>,39 cellRenderer: MarkerCell,40};41
42export default function GettingStartedDemo() {43 const [columns, setColumns] = useState<Grid.Column<GridSpec>[]>([44 { id: "Date", name: "Date", width: 200, type: "datetime", cellRenderer: DateCell },45 { id: "Status", name: "Status", width: 100, cellRenderer: StatusCell },46 { id: "Method", name: "Method", width: 100, cellRenderer: MethodCell },47 { id: "timing-phase", name: "Timing Phase", cellRenderer: TimingPhaseCell },48 { id: "Pathname", name: "Pathname", cellRenderer: PathnameCell },49 { id: "Latency", name: "Latency", width: 120, type: "number", cellRenderer: LatencyCell },50 { id: "region", name: "Region", cellRenderer: RegionCell },51 ]);52
53 const sort = useMemo<UseClientDataSourceParams<GridSpec>["sort"]>(() => {54 const colWithSort = columns.find((x) => x.sort);55 if (!colWithSort) return null;56
57 if (sortComparators[colWithSort.id])58 return [{ dim: sortComparators[colWithSort.id], descending: colWithSort.sort === "desc" }];59
60 return [{ dim: colWithSort, descending: colWithSort.sort === "desc" }];61 }, [columns]);62
63 const ds = useClientDataSource<GridSpec>({64 data: requestData,65 sort,66 });67
68 return (69 <div className="demo ln-grid" style={{ height: 400 }}>70 <Grid71 columns={columns}72 onColumnsChange={setColumns}73 columnBase={base}74 rowSource={ds}75 rowDetailRenderer={RowDetailRenderer}76 columnMarker={marker}77 />78 </div>79 );80}1import type { GridSpec } from "./demo";2import { useMemo } from "react";3import { format } from "date-fns";4import clsx from "clsx";5import type { RequestData } from "./data";6import { PieChart } from "react-minimal-pie-chart";7import { ArrowDownIcon, ArrowUpIcon, ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";8import type { Grid } from "@1771technologies/lytenyte-pro";9
10export function Header({ api, column }: Grid.T.HeaderParams<GridSpec>) {11 return (12 <div13 className="text-ln-gray-60 group relative flex h-full w-full cursor-pointer items-center px-1 text-sm transition-colors"14 onClick={() => {15 const columns = api.props().columns;16 if (!columns) return;17
18 const updates: Record<string, Partial<Grid.Column<GridSpec>>> = {};19 const columnWithSort = columns.filter((x) => x.sort);20 columnWithSort.forEach((x) => {21 updates[x.id] = { sort: null };22 });23
24 if (column.sort === "asc") {25 updates[column.id] = { sort: null };26 } else if (column.sort === "desc") {27 updates[column.id] = { sort: "asc" };28 } else {29 updates[column.id] = { sort: "desc" };30 }31
32 api.columnUpdate(updates);33 }}34 >35 <div className="sort-button flex w-full items-center justify-between rounded px-1 py-1 transition-colors">36 {column.name ?? column.id}37
38 {column.sort === "asc" && <ArrowUpIcon className="text-ln-text-dark size-4" />}39 {column.sort === "desc" && <ArrowDownIcon className="text-ln-text-dark size-4" />}40 </div>41 </div>42 );43}44
45export function MarkerCell({ detailExpanded, row, api }: Grid.T.CellRendererParams<GridSpec>) {46 return (47 <button48 className="text-ln-text flex h-full w-[calc(100%-1px)] cursor-pointer items-center justify-center"49 onClick={() => api.rowDetailToggle(row)}50 >51 {detailExpanded ? (52 <ChevronDownIcon width={20} height={20} />53 ) : (54 <ChevronRightIcon width={20} height={20} />55 )}56 </button>57 );58}59
60export function DateCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const niceDate = useMemo(() => {64 if (typeof field !== "string") return null;65 return format(field, "MMM dd, yyyy HH:mm:ss");66 }, [field]);67
68 // Guard against bad values and render nothing69 if (!niceDate) return null;70
71 return <div className="text-ln-text flex h-full w-full items-center tabular-nums">{niceDate}</div>;72}73
74export function StatusCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {75 const status = api.columnField(column, row);76
77 // Guard against bad values78 if (typeof status !== "number") return null;79
80 return (81 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>82 <div83 className={clsx(84 "rounded-sm px-1 py-px",85 status < 400 && "text-ln-primary-50 bg-[#126CFF1F]",86 status >= 400 && status < 500 && "bg-[#FF991D1C] text-[#EEA760]",87 status >= 500 && "bg-[#e63d3d2d] text-[#e63d3d]",88 )}89 >90 {status}91 </div>92 </div>93 );94}95
96export function MethodCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {97 const method = api.columnField(column, row);98
99 // Guard against bad values100 if (typeof method !== "string") return null;101
102 return (103 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>104 <div105 className={clsx(106 "rounded-sm px-1 py-px",107 method === "GET" && "text-ln-primary-50 bg-[#126CFF1F]",108 (method === "PATCH" || method === "PUT" || method === "POST") && "bg-[#FF991D1C] text-[#EEA760]",109 method === "DELETE" && "bg-[#e63d3d2d] text-[#e63d3d]",110 )}111 >112 {method}113 </div>114 </div>115 );116}117
118export function PathnameCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {119 const path = api.columnField(column, row);120
121 if (typeof path !== "string") return null;122
123 return (124 <div className="text-ln-text-dark flex h-full w-full items-center text-sm">125 <div className="text-ln-primary-50 w-full overflow-hidden text-ellipsis text-nowrap">{path}</div>126 </div>127 );128}129
130const numberFormatter = new Intl.NumberFormat("en-Us", {131 maximumFractionDigits: 0,132 minimumFractionDigits: 0,133});134export function LatencyCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {135 const ms = api.columnField(column, row);136 if (typeof ms !== "number") return null;137
138 return (139 <div className="text-ln-text-dark flex h-full w-full items-center text-sm tabular-nums">140 <div>141 <span className="text-ln-gray-100">{numberFormatter.format(ms)}</span>142 <span className="text-ln-text-light text-xs">ms</span>143 </div>144 </div>145 );146}147
148export function RegionCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {149 // Only render for leaf rows and we have some data150 if (!api.rowIsLeaf(row) || !row.data) return null;151
152 const shortName = row.data["region.shortname"];153 const longName = row.data["region.fullname"];154
155 return (156 <div className="flex h-full w-full items-center">157 <div className="flex items-baseline gap-2 text-sm">158 <div className="text-ln-gray-100">{shortName}</div>159 <div className="text-ln-text-light leading-4">{longName}</div>160 </div>161 </div>162 );163}164
165const colors = ["var(--transfer)", "var(--dns)", "var(--connection)", "var(--ttfb)", "var(--tls)"];166export function TimingPhaseCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {167 // Guard against rows that are not leafs or rows that have no data.168 if (!api.rowIsLeaf(row) || !row.data) return;169
170 const total =171 row.data["timing-phase.connection"] +172 row.data["timing-phase.dns"] +173 row.data["timing-phase.tls"] +174 row.data["timing-phase.transfer"] +175 row.data["timing-phase.ttfb"];176
177 const connectionPer = (row.data["timing-phase.connection"] / total) * 100;178 const dnsPer = (row.data["timing-phase.dns"] / total) * 100;179 const tlPer = (row.data["timing-phase.tls"] / total) * 100;180 const transferPer = (row.data["timing-phase.transfer"] / total) * 100;181 const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;182
183 const values = [connectionPer, dnsPer, tlPer, transferPer, ttfbPer];184
185 return (186 <div className="flex h-full w-full items-center">187 <div className="flex h-4 w-full items-center gap-px overflow-hidden">188 {values.map((v, i) => {189 return (190 <div191 key={i}192 style={{ width: `${v}%`, background: colors[i] }}193 className={clsx("h-full rounded-sm")}194 />195 );196 })}197 </div>198 </div>199 );200}201
202export function RowDetailRenderer({ row, api }: Grid.T.RowParams<GridSpec>) {203 // Guard against empty data.204 if (!api.rowIsLeaf(row) || !row.data) return null;205
206 const total =207 row.data["timing-phase.connection"] +208 row.data["timing-phase.dns"] +209 row.data["timing-phase.tls"] +210 row.data["timing-phase.transfer"] +211 row.data["timing-phase.ttfb"];212
213 const connectionPer = (row.data["timing-phase.connection"] / total) * 100;214 const dnsPer = (row.data["timing-phase.dns"] / total) * 100;215 const tlPer = (row.data["timing-phase.tls"] / total) * 100;216 const transferPer = (row.data["timing-phase.transfer"] / total) * 100;217 const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;218
219 return (220 <div className="pt-1.75 flex h-full flex-col px-4 pb-5 text-sm">221 <h3 className="text-ln-text-xlight mt-0 text-xs font-medium">Timing Phases</h3>222
223 <div className="flex flex-1 gap-2 pt-1.5">224 <div className="bg-ln-gray-00 border-ln-gray-20 h-full flex-1 rounded-[10px] border">225 <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]">226 <TimingPhaseRow227 label="Transfer"228 color={colors[0]}229 msPercentage={transferPer}230 msValue={row.data["timing-phase.transfer"]}231 />232 <TimingPhaseRow233 label="DNS"234 color={colors[1]}235 msPercentage={dnsPer}236 msValue={row.data["timing-phase.dns"]}237 />238 <TimingPhaseRow239 label="Connection"240 color={colors[2]}241 msPercentage={connectionPer}242 msValue={row.data["timing-phase.connection"]}243 />244 <TimingPhaseRow245 label="TTFB"246 color={colors[3]}247 msPercentage={ttfbPer}248 msValue={row.data["timing-phase.ttfb"]}249 />250 <TimingPhaseRow251 label="TLS"252 color={colors[4]}253 msPercentage={tlPer}254 msValue={row.data["timing-phase.tls"]}255 />256
257 <div className="col-start-3 row-span-full flex h-full flex-1 items-center justify-center">258 <TimingPhasePieChart row={row.data} />259 </div>260 </div>261 </div>262 </div>263 </div>264 );265}266
267interface TimePhaseRowProps {268 readonly color: string;269 readonly msValue: number;270 readonly msPercentage: number;271 readonly label: string;272}273
274function TimingPhaseRow({ color, msValue, msPercentage, label }: TimePhaseRowProps) {275 return (276 <>277 <div className="text-sm">{label}</div>278 <div className="text-sm tabular-nums">{msPercentage.toFixed(2)}%</div>279 <div className="col-start-4 hidden items-center justify-end gap-1 text-sm md:flex">280 <div>281 <span className="text-ln-gray-100">{numberFormatter.format(msValue)}</span>282 <span className="text-ln-text-xlight text-xs">ms</span>283 </div>284 <div285 className="rounded"286 style={{287 width: `${msValue}px`,288 height: "12px",289 background: color,290 display: "block",291 }}292 ></div>293 </div>294 </>295 );296}297
298function TimingPhasePieChart({ row }: { row: RequestData }) {299 const data = useMemo(() => {300 return [301 { subject: "Transfer", value: row["timing-phase.transfer"], color: colors[0] },302 { subject: "DNS", value: row["timing-phase.dns"], color: colors[1] },303 { subject: "Connection", value: row["timing-phase.connection"], color: colors[2] },304 { subject: "TTFB", value: row["timing-phase.ttfb"], color: colors[3] },305 { subject: "TLS", value: row["timing-phase.tls"], color: colors[4] },306 ];307 }, [row]);308
309 return (310 <div style={{ height: 100 }}>311 <PieChart data={data} startAngle={180} lengthAngle={180} center={[50, 75]} paddingAngle={1} />312 </div>313 );314}1import type { GridSpec } from "./demo.js";2import type { RequestData } from "./data.js";3import { compareAsc } from "date-fns";4import type { Grid } from "@1771technologies/lytenyte-pro";5
6export const sortComparators: Record<string, Grid.T.SortFn<GridSpec["data"]>> = {7 region: (left, right) => {8 if (left.kind !== "leaf" && right.kind !== "leaf") return 0;9 if (left.kind === "leaf" && right.kind !== "leaf") return -1;10 if (left.kind !== "leaf" && right.kind === "leaf") return 1;11
12 const leftData = left.data as RequestData;13 const rightData = right.data as RequestData;14
15 return leftData["region.fullname"].localeCompare(rightData["region.fullname"]);16 },17 "timing-phase": (left, right) => {18 if (left.kind !== "leaf" && right.kind !== "leaf") return 0;19 if (left.kind === "leaf" && right.kind !== "leaf") return -1;20 if (left.kind !== "leaf" && right.kind === "leaf") return 1;21
22 const leftData = left.data as RequestData;23 const rightData = right.data as RequestData;24
25 return leftData.Latency - rightData.Latency;26 },27 Date: (left, right) => {28 if (left.kind !== "leaf" && right.kind !== "leaf") return 0;29 if (left.kind === "leaf" && right.kind !== "leaf") return -1;30 if (left.kind !== "leaf" && right.kind === "leaf") return 1;31
32 const leftData = left.data as RequestData;33 const rightData = right.data as RequestData;34
35 return compareAsc(leftData.Date, rightData.Date);36 },37};Row Detail Height
Use rowDetailHeight property on the grid 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
19 collapsed lines
1import "./main.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";4
5import type { RequestData } from "./data.js";6import { requestData } from "./data.js";7import {8 DateCell,9 Header,10 LatencyCell,11 MarkerCell,12 MethodCell,13 PathnameCell,14 RegionCell,15 RowDetailRenderer,16 StatusCell,17 TimingPhaseCell,18} from "./components.jsx";19import { useState } from "react";20
21export interface GridSpec {22 data: RequestData;23}24
25const columns: Grid.Column<GridSpec>[] = [26 { id: "Date", name: "Date", width: 200, type: "datetime", cellRenderer: DateCell },27 { id: "Status", name: "Status", width: 100, cellRenderer: StatusCell },28 { id: "Method", name: "Method", width: 100, cellRenderer: MethodCell },29 { id: "timing-phase", name: "Timing Phase", cellRenderer: TimingPhaseCell },30 { id: "Pathname", name: "Pathname", cellRenderer: PathnameCell },31 { id: "Latency", name: "Latency", width: 120, type: "number", cellRenderer: LatencyCell },32 { id: "region", name: "Region", cellRenderer: RegionCell },33];34
35const base: Grid.Props<GridSpec>["columnBase"] = {36 headerRenderer: Header,37};38
39const marker: Grid.Props<GridSpec>["columnMarker"] = {40 on: true,41 width: 40,42 headerRenderer: () => <div className="sr-only">Toggle row detail expansion</div>,43 cellRenderer: MarkerCell,44};45
46export default function RowDemo() {47 const ds = useClientDataSource<GridSpec>({48 data: requestData,49 });50
51 const [rowDetailExpansions, setRowDetailExpansions] = useState(new Set(["leaf-0"]));52
53 return (54 <div className="demo ln-grid" style={{ height: 400 }}>55 <Grid56 columns={columns}57 columnBase={base}58 rowSource={ds}59 rowDetailRenderer={RowDetailRenderer}60 rowDetailExpansions={rowDetailExpansions}61 onRowDetailExpansionsChange={setRowDetailExpansions}62 rowDetailHeight="auto"63 columnMarker={marker}64 />65 </div>66 );67}1import type { GridSpec } from "./demo";2import { useMemo } from "react";3import { format } from "date-fns";4import clsx from "clsx";5import type { RequestData } from "./data";6import { PieChart } from "react-minimal-pie-chart";7import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";8import type { Grid } from "@1771technologies/lytenyte-pro";9
10export function Header({ column }: Grid.T.HeaderParams<GridSpec>) {11 return (12 <div className="text-ln-gray-60 group relative flex h-full w-full cursor-pointer items-center px-1 text-sm transition-colors">13 <div className="sort-button flex w-full items-center justify-between rounded px-1 py-1 transition-colors">14 {column.name ?? column.id}15 </div>16 </div>17 );18}19
20export function MarkerCell({ detailExpanded, row, api }: Grid.T.CellRendererParams<GridSpec>) {21 return (22 <button23 className="text-ln-text flex h-full w-[calc(100%-1px)] cursor-pointer items-center justify-center"24 onClick={() => api.rowDetailToggle(row)}25 >26 {detailExpanded ? (27 <ChevronDownIcon width={20} height={20} />28 ) : (29 <ChevronRightIcon width={20} height={20} />30 )}31 </button>32 );33}34
35export function DateCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {36 const field = api.columnField(column, row);37
38 const niceDate = useMemo(() => {39 if (typeof field !== "string") return null;40 return format(field, "MMM dd, yyyy HH:mm:ss");41 }, [field]);42
43 // Guard against bad values and render nothing44 if (!niceDate) return null;45
46 return <div className="text-ln-text flex h-full w-full items-center tabular-nums">{niceDate}</div>;47}48
49export function StatusCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {50 const status = api.columnField(column, row);51
52 // Guard against bad values53 if (typeof status !== "number") return null;54
55 return (56 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>57 <div58 className={clsx(59 "rounded-sm px-1 py-px",60 status < 400 && "text-ln-primary-50 bg-[#126CFF1F]",61 status >= 400 && status < 500 && "bg-[#FF991D1C] text-[#EEA760]",62 status >= 500 && "bg-[#e63d3d2d] text-[#e63d3d]",63 )}64 >65 {status}66 </div>67 </div>68 );69}70
71export function MethodCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {72 const method = api.columnField(column, row);73
74 // Guard against bad values75 if (typeof method !== "string") return null;76
77 return (78 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>79 <div80 className={clsx(81 "rounded-sm px-1 py-px",82 method === "GET" && "text-ln-primary-50 bg-[#126CFF1F]",83 (method === "PATCH" || method === "PUT" || method === "POST") && "bg-[#FF991D1C] text-[#EEA760]",84 method === "DELETE" && "bg-[#e63d3d2d] text-[#e63d3d]",85 )}86 >87 {method}88 </div>89 </div>90 );91}92
93export function PathnameCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {94 const path = api.columnField(column, row);95
96 if (typeof path !== "string") return null;97
98 return (99 <div className="text-ln-text-dark flex h-full w-full items-center text-sm">100 <div className="text-ln-primary-50 w-full overflow-hidden text-ellipsis text-nowrap">{path}</div>101 </div>102 );103}104
105const numberFormatter = new Intl.NumberFormat("en-Us", {106 maximumFractionDigits: 0,107 minimumFractionDigits: 0,108});109export function LatencyCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {110 const ms = api.columnField(column, row);111 if (typeof ms !== "number") return null;112
113 return (114 <div className="text-ln-text-dark flex h-full w-full items-center text-sm tabular-nums">115 <div>116 <span className="text-ln-gray-100">{numberFormatter.format(ms)}</span>117 <span className="text-ln-text-light text-xs">ms</span>118 </div>119 </div>120 );121}122
123export function RegionCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {124 // Only render for leaf rows and we have some data125 if (!api.rowIsLeaf(row) || !row.data) return null;126
127 const shortName = row.data["region.shortname"];128 const longName = row.data["region.fullname"];129
130 return (131 <div className="flex h-full w-full items-center">132 <div className="flex items-baseline gap-2 text-sm">133 <div className="text-ln-gray-100">{shortName}</div>134 <div className="text-ln-text-light leading-4">{longName}</div>135 </div>136 </div>137 );138}139
140const colors = ["var(--transfer)", "var(--dns)", "var(--connection)", "var(--ttfb)", "var(--tls)"];141export function TimingPhaseCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {142 // Guard against rows that are not leafs or rows that have no data.143 if (!api.rowIsLeaf(row) || !row.data) return;144
145 const total =146 row.data["timing-phase.connection"] +147 row.data["timing-phase.dns"] +148 row.data["timing-phase.tls"] +149 row.data["timing-phase.transfer"] +150 row.data["timing-phase.ttfb"];151
152 const connectionPer = (row.data["timing-phase.connection"] / total) * 100;153 const dnsPer = (row.data["timing-phase.dns"] / total) * 100;154 const tlPer = (row.data["timing-phase.tls"] / total) * 100;155 const transferPer = (row.data["timing-phase.transfer"] / total) * 100;156 const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;157
158 const values = [connectionPer, dnsPer, tlPer, transferPer, ttfbPer];159
160 return (161 <div className="flex h-full w-full items-center">162 <div className="flex h-4 w-full items-center gap-px overflow-hidden">163 {values.map((v, i) => {164 return (165 <div166 key={i}167 style={{ width: `${v}%`, background: colors[i] }}168 className={clsx("h-full rounded-sm")}169 />170 );171 })}172 </div>173 </div>174 );175}176
177export function RowDetailRenderer({ row, api }: Grid.T.RowParams<GridSpec>) {178 // Guard against empty data.179 if (!api.rowIsLeaf(row) || !row.data) return null;180
181 const total =182 row.data["timing-phase.connection"] +183 row.data["timing-phase.dns"] +184 row.data["timing-phase.tls"] +185 row.data["timing-phase.transfer"] +186 row.data["timing-phase.ttfb"];187
188 const connectionPer = (row.data["timing-phase.connection"] / total) * 100;189 const dnsPer = (row.data["timing-phase.dns"] / total) * 100;190 const tlPer = (row.data["timing-phase.tls"] / total) * 100;191 const transferPer = (row.data["timing-phase.transfer"] / total) * 100;192 const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;193
194 return (195 <div className="flex h-full flex-col px-4 py-8 text-sm">196 <h3 className="text-ln-text-xlight mt-0 text-xs font-medium">Timing Phases</h3>197
198 <div className="flex flex-1 gap-2 pt-1.5">199 <div className="bg-ln-gray-00 border-ln-gray-20 h-full flex-1 rounded-[10px] border">200 <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]">201 <TimingPhaseRow202 label="Transfer"203 color={colors[0]}204 msPercentage={transferPer}205 msValue={row.data["timing-phase.transfer"]}206 />207 <TimingPhaseRow208 label="DNS"209 color={colors[1]}210 msPercentage={dnsPer}211 msValue={row.data["timing-phase.dns"]}212 />213 <TimingPhaseRow214 label="Connection"215 color={colors[2]}216 msPercentage={connectionPer}217 msValue={row.data["timing-phase.connection"]}218 />219 <TimingPhaseRow220 label="TTFB"221 color={colors[3]}222 msPercentage={ttfbPer}223 msValue={row.data["timing-phase.ttfb"]}224 />225 <TimingPhaseRow226 label="TLS"227 color={colors[4]}228 msPercentage={tlPer}229 msValue={row.data["timing-phase.tls"]}230 />231
232 <div className="col-start-3 row-span-full flex h-full flex-1 items-center justify-center">233 <TimingPhasePieChart row={row.data} />234 </div>235 </div>236 </div>237 </div>238 </div>239 );240}241
242interface TimePhaseRowProps {243 readonly color: string;244 readonly msValue: number;245 readonly msPercentage: number;246 readonly label: string;247}248
249function TimingPhaseRow({ color, msValue, msPercentage, label }: TimePhaseRowProps) {250 return (251 <>252 <div className="text-sm">{label}</div>253 <div className="text-sm tabular-nums">{msPercentage.toFixed(2)}%</div>254 <div className="col-start-4 hidden items-center justify-end gap-1 text-sm md:flex">255 <div>256 <span className="text-ln-gray-100">{numberFormatter.format(msValue)}</span>257 <span className="text-ln-text-xlight text-xs">ms</span>258 </div>259 <div260 className="rounded"261 style={{262 width: `${msValue}px`,263 height: "12px",264 background: color,265 display: "block",266 }}267 ></div>268 </div>269 </>270 );271}272
273function TimingPhasePieChart({ row }: { row: RequestData }) {274 const data = useMemo(() => {275 return [276 { subject: "Transfer", value: row["timing-phase.transfer"], color: colors[0] },277 { subject: "DNS", value: row["timing-phase.dns"], color: colors[1] },278 { subject: "Connection", value: row["timing-phase.connection"], color: colors[2] },279 { subject: "TTFB", value: row["timing-phase.ttfb"], color: colors[3] },280 { subject: "TLS", value: row["timing-phase.tls"], color: colors[4] },281 ];282 }, [row]);283
284 return (285 <div style={{ height: 100 }}>286 <PieChart data={data} startAngle={180} lengthAngle={180} center={[50, 75]} paddingAngle={1} />287 </div>288 );289}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
19 collapsed lines
1import "./main.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";4
5import type { RequestData } from "./data.js";6import { requestData } from "./data.js";7import {8 DateCell,9 Header,10 LatencyCell,11 MarkerCell,12 MethodCell,13 PathnameCell,14 RegionCell,15 RowDetailRenderer,16 StatusCell,17 TimingPhaseCell,18} from "./components.jsx";19import { useState } from "react";20
21export interface GridSpec {22 data: RequestData;23}24
25const columns: Grid.Column<GridSpec>[] = [26 { id: "Date", name: "Date", width: 200, type: "datetime", cellRenderer: DateCell },27 { id: "Status", name: "Status", width: 100, cellRenderer: StatusCell },28 { id: "Method", name: "Method", width: 100, cellRenderer: MethodCell },29 { id: "timing-phase", name: "Timing Phase", cellRenderer: TimingPhaseCell },30 { id: "Pathname", name: "Pathname", cellRenderer: PathnameCell },31 { id: "Latency", name: "Latency", width: 120, type: "number", cellRenderer: LatencyCell },32 { id: "region", name: "Region", cellRenderer: RegionCell },33];34
35const base: Grid.Props<GridSpec>["columnBase"] = {36 headerRenderer: Header,37};38
39const marker: Grid.Props<GridSpec>["columnMarker"] = {40 on: true,41 width: 40,42 headerRenderer: () => <div className="sr-only">Toggle row detail expansion</div>,43 cellRenderer: MarkerCell,44};45
46export default function RowDemo() {47 const ds = useClientDataSource<GridSpec>({48 data: requestData,49 });50
51 const [rowDetailExpansions, setRowDetailExpansions] = useState(new Set(["leaf-0"]));52
53 return (54 <div className="ln-grid" style={{ height: 400 }}>55 <Grid56 columns={columns}57 columnBase={base}58 rowSource={ds}59 rowDetailRenderer={RowDetailRenderer}60 rowDetailExpansions={rowDetailExpansions}61 onRowDetailExpansionsChange={setRowDetailExpansions}62 columnMarker={marker}63 rowDetailHeight={120}64 />65 </div>66 );67}1import type { GridSpec } from "./demo";2import { useMemo } from "react";3import { format } from "date-fns";4import clsx from "clsx";5import type { RequestData } from "./data";6import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";7import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";8
9export function Header({ column }: Grid.T.HeaderParams<GridSpec>) {10 return (11 <div className="text-ln-gray-60 group relative flex h-full w-full cursor-pointer items-center text-sm transition-colors">12 <div className="sort-button flex w-full items-center justify-between rounded px-1 py-1 transition-colors">13 {column.name ?? column.id}14 </div>15 </div>16 );17}18
19export function MarkerCell({ detailExpanded, row, api }: Grid.T.CellRendererParams<GridSpec>) {20 return (21 <button22 className="text-ln-text flex h-full w-[calc(100%-1px)] cursor-pointer items-center justify-center"23 onClick={() => api.rowDetailToggle(row)}24 >25 {detailExpanded ? (26 <ChevronDownIcon width={20} height={20} />27 ) : (28 <ChevronRightIcon width={20} height={20} />29 )}30 </button>31 );32}33
34export function DateCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {35 const field = api.columnField(column, row);36
37 const niceDate = useMemo(() => {38 if (typeof field !== "string") return null;39 return format(field, "MMM dd, yyyy HH:mm:ss");40 }, [field]);41
42 // Guard against bad values and render nothing43 if (!niceDate) return null;44
45 return <div className="text-ln-text flex h-full w-full items-center tabular-nums">{niceDate}</div>;46}47
48export function StatusCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {49 const status = api.columnField(column, row);50
51 // Guard against bad values52 if (typeof status !== "number") return null;53
54 return (55 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>56 <div57 className={clsx(58 "rounded-sm px-1 py-px",59 status < 400 && "text-ln-primary-50 bg-[#126CFF1F]",60 status >= 400 && status < 500 && "bg-[#FF991D1C] text-[#EEA760]",61 status >= 500 && "bg-[#e63d3d2d] text-[#e63d3d]",62 )}63 >64 {status}65 </div>66 </div>67 );68}69
70export function MethodCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {71 const method = api.columnField(column, row);72
73 // Guard against bad values74 if (typeof method !== "string") return null;75
76 return (77 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>78 <div79 className={clsx(80 "rounded-sm px-1 py-px",81 method === "GET" && "text-ln-primary-50 bg-[#126CFF1F]",82 (method === "PATCH" || method === "PUT" || method === "POST") && "bg-[#FF991D1C] text-[#EEA760]",83 method === "DELETE" && "bg-[#e63d3d2d] text-[#e63d3d]",84 )}85 >86 {method}87 </div>88 </div>89 );90}91
92export function PathnameCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {93 const path = api.columnField(column, row);94
95 if (typeof path !== "string") return null;96
97 return (98 <div className="text-ln-text-dark flex h-full w-full items-center text-sm">99 <div className="text-ln-primary-50 w-full overflow-hidden text-ellipsis text-nowrap">{path}</div>100 </div>101 );102}103
104const numberFormatter = new Intl.NumberFormat("en-Us", {105 maximumFractionDigits: 0,106 minimumFractionDigits: 0,107});108export function LatencyCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {109 const ms = api.columnField(column, row);110 if (typeof ms !== "number") return null;111
112 return (113 <div className="text-ln-text-dark flex h-full w-full items-center text-sm tabular-nums">114 <div>115 <span className="text-ln-gray-100">{numberFormatter.format(ms)}</span>116 <span className="text-ln-text-light text-xs">ms</span>117 </div>118 </div>119 );120}121
122export function RegionCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {123 // Only render for leaf rows and we have some data124 if (!api.rowIsLeaf(row) || !row.data) return null;125
126 const shortName = row.data["region.shortname"];127 const longName = row.data["region.fullname"];128
129 return (130 <div className="flex h-full w-full items-center">131 <div className="flex items-baseline gap-2 text-sm">132 <div className="text-ln-gray-100">{shortName}</div>133 <div className="text-ln-text-light leading-4">{longName}</div>134 </div>135 </div>136 );137}138
139const colors = ["var(--transfer)", "var(--dns)", "var(--connection)", "var(--ttfb)", "var(--tls)"];140export function TimingPhaseCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {141 // Guard against rows that are not leafs or rows that have no data.142 if (!api.rowIsLeaf(row) || !row.data) return;143
144 const total =145 row.data["timing-phase.connection"] +146 row.data["timing-phase.dns"] +147 row.data["timing-phase.tls"] +148 row.data["timing-phase.transfer"] +149 row.data["timing-phase.ttfb"];150
151 const connectionPer = (row.data["timing-phase.connection"] / total) * 100;152 const dnsPer = (row.data["timing-phase.dns"] / total) * 100;153 const tlPer = (row.data["timing-phase.tls"] / total) * 100;154 const transferPer = (row.data["timing-phase.transfer"] / total) * 100;155 const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;156
157 const values = [connectionPer, dnsPer, tlPer, transferPer, ttfbPer];158
159 return (160 <div className="flex h-full w-full items-center">161 <div className="flex h-4 w-full items-center gap-px overflow-hidden">162 {values.map((v, i) => {163 return (164 <div165 key={i}166 style={{ width: `${v}%`, background: colors[i] }}167 className={clsx("h-full rounded-sm")}168 />169 );170 })}171 </div>172 </div>173 );174}175
176const nestedColumns = [177 { id: "connectionPer", name: "Connection %" },178 { id: "dnsPer", name: "DNS %" },179 { id: "tlPer", name: "TL %" },180 { id: "transferPer", name: "Transfer %" },181 { id: "ttfbPer", name: "TTFB %" },182];183
184const base: Grid.ColumnBase = {185 width: 100,186 widthFlex: 1,187 headerRenderer: (p) => <Header {...(p as any)} />,188 cellRenderer: (p) => {189 const field = p.api.columnField(p.column, p.row) as number;190
191 return (192 <div className="text-ln-gray-100 flex h-full w-full items-center text-sm tabular-nums">193 {field.toFixed(2)}%194 </div>195 );196 },197};198export function RowDetailRenderer({ row }: Grid.T.RowParams<GridSpec>) {199 const data = row.data! as RequestData;200
201 const total =202 data["timing-phase.connection"] +203 data["timing-phase.dns"] +204 data["timing-phase.tls"] +205 data["timing-phase.transfer"] +206 data["timing-phase.ttfb"];207
208 const connectionPer = (data["timing-phase.connection"] / total) * 100;209 const dnsPer = (data["timing-phase.dns"] / total) * 100;210 const tlPer = (data["timing-phase.tls"] / total) * 100;211 const transferPer = (data["timing-phase.transfer"] / total) * 100;212 const ttfbPer = (data["timing-phase.ttfb"] / total) * 100;213
214 const ds = useClientDataSource({215 data: [216 {217 connectionPer,218 dnsPer,219 tlPer,220 transferPer,221 ttfbPer,222 },223 ],224 });225
226 return (227 <div className="flex h-full w-full items-center px-8">228 <div className="border-ln-gray-20 h-24 w-full overflow-hidden rounded-lg border">229 <Grid rowHeight="fill:24" columns={nestedColumns} rowSource={ds} columnBase={base} />230 </div>231 </div>232 );233}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.
Controlled Detail Expansion State
The rowDetailExpansions property on the grid determines which rows have their detail area
expanded. If you do not set rowDetailExpansions on the grid, LyteNyte Grid tracks detail
expansion using internal, uncontrolled state.
By providing a rowDetailExpansions value, you can control which rows have their
detail area expanded to align with your application’s intended behavior.
By listening to onRowDetailExpansionChange, you can update your controlled
expansion state accordingly.
A typical use case is to limit the grid to display only one expanded row detail area at a time, so that expanding a different row’s detail area automatically collapses any other open detail areas. The demo below demonstrates this behavior.
Controlled Detail Expansions
19 collapsed lines
1import "./main.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";4
5import type { RequestData } from "./data.js";6import { requestData } from "./data.js";7import {8 DateCell,9 Header,10 LatencyCell,11 MarkerCell,12 MethodCell,13 PathnameCell,14 RegionCell,15 RowDetailRenderer,16 StatusCell,17 TimingPhaseCell,18} from "./components.jsx";19import { useState } from "react";20
21export interface GridSpec {22 data: RequestData;23}24
25const columns: Grid.Column<GridSpec>[] = [26 { id: "Date", name: "Date", width: 200, type: "datetime", cellRenderer: DateCell },27 { id: "Status", name: "Status", width: 100, cellRenderer: StatusCell },28 { id: "Method", name: "Method", width: 100, cellRenderer: MethodCell },29 { id: "timing-phase", name: "Timing Phase", cellRenderer: TimingPhaseCell },30 { id: "Pathname", name: "Pathname", cellRenderer: PathnameCell },31 { id: "Latency", name: "Latency", width: 120, type: "number", cellRenderer: LatencyCell },32 { id: "region", name: "Region", cellRenderer: RegionCell },33];34
35const base: Grid.Props<GridSpec>["columnBase"] = {36 headerRenderer: Header,37};38
39const marker: Grid.Props<GridSpec>["columnMarker"] = {40 on: true,41 width: 40,42 headerRenderer: () => <div className="sr-only">Toggle row detail expansion</div>,43 cellRenderer: MarkerCell,44};45
46export default function RowDemo() {47 const ds = useClientDataSource<GridSpec>({48 data: requestData,49 });50
51 const [rowDetailExpansions, setRowDetailExpansions] = useState(new Set(["leaf-0"]));52
53 return (54 <div className="demo ln-grid" style={{ height: 400 }}>55 <Grid56 columns={columns}57 columnBase={base}58 rowSource={ds}59 rowDetailRenderer={RowDetailRenderer}60 rowDetailExpansions={rowDetailExpansions}61 onRowDetailExpansionsChange={(newSet) => {62 setRowDetailExpansions((prev) => {63 return newSet.difference(prev);64 });65 }}66 columnMarker={marker}67 />68 </div>69 );70}1import type { GridSpec } from "./demo";2import { useMemo } from "react";3import { format } from "date-fns";4import clsx from "clsx";5import type { RequestData } from "./data";6import { PieChart } from "react-minimal-pie-chart";7import { ChevronDownIcon, ChevronRightIcon } from "@radix-ui/react-icons";8import type { Grid } from "@1771technologies/lytenyte-pro";9
10export function Header({ column }: Grid.T.HeaderParams<GridSpec>) {11 return (12 <div className="text-ln-gray-60 group relative flex h-full w-full cursor-pointer items-center px-1 text-sm transition-colors">13 <div className="sort-button flex w-full items-center justify-between rounded px-1 py-1 transition-colors">14 {column.name ?? column.id}15 </div>16 </div>17 );18}19
20export function MarkerCell({ detailExpanded, row, api }: Grid.T.CellRendererParams<GridSpec>) {21 return (22 <button23 className="text-ln-text flex h-full w-[calc(100%-1px)] cursor-pointer items-center justify-center"24 onClick={() => api.rowDetailToggle(row)}25 >26 {detailExpanded ? (27 <ChevronDownIcon width={20} height={20} />28 ) : (29 <ChevronRightIcon width={20} height={20} />30 )}31 </button>32 );33}34
35export function DateCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {36 const field = api.columnField(column, row);37
38 const niceDate = useMemo(() => {39 if (typeof field !== "string") return null;40 return format(field, "MMM dd, yyyy HH:mm:ss");41 }, [field]);42
43 // Guard against bad values and render nothing44 if (!niceDate) return null;45
46 return <div className="text-ln-text flex h-full w-full items-center tabular-nums">{niceDate}</div>;47}48
49export function StatusCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {50 const status = api.columnField(column, row);51
52 // Guard against bad values53 if (typeof status !== "number") return null;54
55 return (56 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>57 <div58 className={clsx(59 "rounded-sm px-1 py-px",60 status < 400 && "text-ln-primary-50 bg-[#126CFF1F]",61 status >= 400 && status < 500 && "bg-[#FF991D1C] text-[#EEA760]",62 status >= 500 && "bg-[#e63d3d2d] text-[#e63d3d]",63 )}64 >65 {status}66 </div>67 </div>68 );69}70
71export function MethodCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {72 const method = api.columnField(column, row);73
74 // Guard against bad values75 if (typeof method !== "string") return null;76
77 return (78 <div className={clsx("flex h-full w-full items-center text-xs font-bold")}>79 <div80 className={clsx(81 "rounded-sm px-1 py-px",82 method === "GET" && "text-ln-primary-50 bg-[#126CFF1F]",83 (method === "PATCH" || method === "PUT" || method === "POST") && "bg-[#FF991D1C] text-[#EEA760]",84 method === "DELETE" && "bg-[#e63d3d2d] text-[#e63d3d]",85 )}86 >87 {method}88 </div>89 </div>90 );91}92
93export function PathnameCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {94 const path = api.columnField(column, row);95
96 if (typeof path !== "string") return null;97
98 return (99 <div className="text-ln-text-dark flex h-full w-full items-center text-sm">100 <div className="text-ln-primary-50 w-full overflow-hidden text-ellipsis text-nowrap">{path}</div>101 </div>102 );103}104
105const numberFormatter = new Intl.NumberFormat("en-Us", {106 maximumFractionDigits: 0,107 minimumFractionDigits: 0,108});109export function LatencyCell({ column, row, api }: Grid.T.CellRendererParams<GridSpec>) {110 const ms = api.columnField(column, row);111 if (typeof ms !== "number") return null;112
113 return (114 <div className="text-ln-text-dark flex h-full w-full items-center text-sm tabular-nums">115 <div>116 <span className="text-ln-gray-100">{numberFormatter.format(ms)}</span>117 <span className="text-ln-text-light text-xs">ms</span>118 </div>119 </div>120 );121}122
123export function RegionCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {124 // Only render for leaf rows and we have some data125 if (!api.rowIsLeaf(row) || !row.data) return null;126
127 const shortName = row.data["region.shortname"];128 const longName = row.data["region.fullname"];129
130 return (131 <div className="flex h-full w-full items-center">132 <div className="flex items-baseline gap-2 text-sm">133 <div className="text-ln-gray-100">{shortName}</div>134 <div className="text-ln-text-light leading-4">{longName}</div>135 </div>136 </div>137 );138}139
140const colors = ["var(--transfer)", "var(--dns)", "var(--connection)", "var(--ttfb)", "var(--tls)"];141export function TimingPhaseCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {142 // Guard against rows that are not leafs or rows that have no data.143 if (!api.rowIsLeaf(row) || !row.data) return;144
145 const total =146 row.data["timing-phase.connection"] +147 row.data["timing-phase.dns"] +148 row.data["timing-phase.tls"] +149 row.data["timing-phase.transfer"] +150 row.data["timing-phase.ttfb"];151
152 const connectionPer = (row.data["timing-phase.connection"] / total) * 100;153 const dnsPer = (row.data["timing-phase.dns"] / total) * 100;154 const tlPer = (row.data["timing-phase.tls"] / total) * 100;155 const transferPer = (row.data["timing-phase.transfer"] / total) * 100;156 const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;157
158 const values = [connectionPer, dnsPer, tlPer, transferPer, ttfbPer];159
160 return (161 <div className="flex h-full w-full items-center">162 <div className="flex h-4 w-full items-center gap-px overflow-hidden">163 {values.map((v, i) => {164 return (165 <div166 key={i}167 style={{ width: `${v}%`, background: colors[i] }}168 className={clsx("h-full rounded-sm")}169 />170 );171 })}172 </div>173 </div>174 );175}176
177export function RowDetailRenderer({ row, api }: Grid.T.RowParams<GridSpec>) {178 // Guard against empty data.179 if (!api.rowIsLeaf(row) || !row.data) return null;180
181 const total =182 row.data["timing-phase.connection"] +183 row.data["timing-phase.dns"] +184 row.data["timing-phase.tls"] +185 row.data["timing-phase.transfer"] +186 row.data["timing-phase.ttfb"];187
188 const connectionPer = (row.data["timing-phase.connection"] / total) * 100;189 const dnsPer = (row.data["timing-phase.dns"] / total) * 100;190 const tlPer = (row.data["timing-phase.tls"] / total) * 100;191 const transferPer = (row.data["timing-phase.transfer"] / total) * 100;192 const ttfbPer = (row.data["timing-phase.ttfb"] / total) * 100;193
194 return (195 <div className="flex h-full flex-col px-4 pb-5 pt-1.5 text-sm">196 <h3 className="text-ln-text-xlight mt-0 text-xs font-medium">Timing Phases</h3>197
198 <div className="flex flex-1 gap-2 pt-1.5">199 <div className="bg-ln-gray-00 border-ln-gray-20 h-full flex-1 rounded-[10px] border">200 <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]">201 <TimingPhaseRow202 label="Transfer"203 color={colors[0]}204 msPercentage={transferPer}205 msValue={row.data["timing-phase.transfer"]}206 />207 <TimingPhaseRow208 label="DNS"209 color={colors[1]}210 msPercentage={dnsPer}211 msValue={row.data["timing-phase.dns"]}212 />213 <TimingPhaseRow214 label="Connection"215 color={colors[2]}216 msPercentage={connectionPer}217 msValue={row.data["timing-phase.connection"]}218 />219 <TimingPhaseRow220 label="TTFB"221 color={colors[3]}222 msPercentage={ttfbPer}223 msValue={row.data["timing-phase.ttfb"]}224 />225 <TimingPhaseRow226 label="TLS"227 color={colors[4]}228 msPercentage={tlPer}229 msValue={row.data["timing-phase.tls"]}230 />231
232 <div className="col-start-3 row-span-full flex h-full flex-1 items-center justify-center">233 <TimingPhasePieChart row={row.data} />234 </div>235 </div>236 </div>237 </div>238 </div>239 );240}241
242interface TimePhaseRowProps {243 readonly color: string;244 readonly msValue: number;245 readonly msPercentage: number;246 readonly label: string;247}248
249function TimingPhaseRow({ color, msValue, msPercentage, label }: TimePhaseRowProps) {250 return (251 <>252 <div className="text-sm">{label}</div>253 <div className="text-sm tabular-nums">{msPercentage.toFixed(2)}%</div>254 <div className="col-start-4 hidden items-center justify-end gap-1 text-sm md:flex">255 <div>256 <span className="text-ln-gray-100">{numberFormatter.format(msValue)}</span>257 <span className="text-ln-text-xlight text-xs">ms</span>258 </div>259 <div260 className="rounded"261 style={{262 width: `${msValue}px`,263 height: "12px",264 background: color,265 display: "block",266 }}267 ></div>268 </div>269 </>270 );271}272
273function TimingPhasePieChart({ row }: { row: RequestData }) {274 const data = useMemo(() => {275 return [276 { subject: "Transfer", value: row["timing-phase.transfer"], color: colors[0] },277 { subject: "DNS", value: row["timing-phase.dns"], color: colors[1] },278 { subject: "Connection", value: row["timing-phase.connection"], color: colors[2] },279 { subject: "TTFB", value: row["timing-phase.ttfb"], color: colors[3] },280 { subject: "TLS", value: row["timing-phase.tls"], color: colors[4] },281 ];282 }, [row]);283
284 return (285 <div style={{ height: 100 }}>286 <PieChart data={data} startAngle={180} lengthAngle={180} center={[50, 75]} paddingAngle={1} />287 </div>288 );289}Next Steps
- Row Height: Change row height, support variable-height rows, and configure 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.
