Client Row Grouping
Group rows by one or more dimensions to create a hierarchical representation of your data. Row groups can be uniform, with equal dimensions, or non-uniform, with varying dimensions.
The group property on the useClientDataSource hook accepts one of two
value types:
- A function that returns a group path array.
- An array of grouping dimensions.
Use the group property to create a hierarchical row structure. When grouping is enabled, the client source creates two row types: leaf and group rows.
If you are unfamiliar with the different row node types in LyteNyte Grid, see the Row Overview guide.
Info
This guide covers the client row source’s grouping functionality. Grouping is often used with row aggregations. Review the Client Row Aggregations guide for more details.
Grouping Function
Provide useClientDataSource with a function that returns an array of path values to group by.
A path value is any string or null value.
The demo below uses a grouping function to group rows by Job and Education.
Function Row Grouping
42 collapsed lines
1import "@1771technologies/lytenyte-pro/light-dark.css";2import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";3import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";4import {5 CountryCell,6 CustomerRating,7 DateCell,8 DurationCell,9 NameCell,10 NumberCell,11 OverdueCell,12} from "./components.js";13import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";14
15export interface GridSpec {16 readonly data: LoanDataItem;17}18
19const columns: Grid.Column<GridSpec>[] = [20 { name: "Name", id: "name", cellRenderer: NameCell, width: 110 },21 { name: "Country", id: "country", width: 150, cellRenderer: CountryCell },22 { name: "Loan Amount", id: "loanAmount", width: 120, type: "number", cellRenderer: NumberCell },23 { name: "Balance", id: "balance", type: "number", cellRenderer: NumberCell },24 { name: "Customer Rating", id: "customerRating", type: "number", width: 125, cellRenderer: CustomerRating },25 { name: "Marital", id: "marital" },26 { name: "Education", id: "education", hide: true },27 { name: "Job", id: "job", width: 120, hide: true },28 { name: "Overdue", id: "overdue", cellRenderer: OverdueCell },29 { name: "Duration", id: "duration", type: "number", cellRenderer: DurationCell },30 { name: "Date", id: "date", width: 110, cellRenderer: DateCell },31 { name: "Age", id: "age", width: 80, type: "number" },32 { name: "Contact", id: "contact" },33];34
35const base: Grid.ColumnBase<GridSpec> = { width: 100 };36
37const group: Grid.RowGroupColumn<GridSpec> = {38 cellRenderer: RowGroupCell,39 width: 200,40 pin: "start",41};42
43
44const groupFn: Grid.T.GroupFn<GridSpec["data"]> = (row) => {45 return [row.data.job, row.data.education];46};47
48export default function ClientDemo() {49 const ds = useClientDataSource<GridSpec>({50 data: loanData,51 group: groupFn,52 rowGroupDefaultExpansion: true,53 });54
55 return (56 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>57 <Grid rowSource={ds} columns={columns} columnBase={base} rowGroupColumn={group} />58 </div>59 );60}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);23
24 return (25 <div26 className={tw(27 "flex h-full w-full items-center justify-end tabular-nums",28 field < 0 && "text-red-600 dark:text-red-300",29 field > 0 && "text-green-600 dark:text-green-300",30 )}31 >32 {formatted}33 </div>34 );35}36
37export function NameCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {38 if (!api.rowIsLeaf(row) || !row.data) return;39
40 const url = row.data?.avatar;41
42 const name = row.data.name;43
44 return (45 <div className="flex h-full w-full items-center gap-2">46 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />47 <div className="text-ln-text-dark flex flex-col gap-0.5">48 <div>{name}</div>49 </div>50 </div>51 );52}53
54export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {55 const field = api.columnField(column, row);56
57 return typeof field === "number" ? `${formatter.format(field)} days` : `${field ?? "-"}`;58}59
60export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const flag = countryFlags[field as keyof typeof countryFlags];64 if (!flag) return "-";65
66 return (67 <div className="flex h-full w-full items-center gap-2">68 <img className="size-4" src={flag} alt={`country flag of ${field}`} />69 <span>{String(field ?? "-")}</span>70 </div>71 );72}73
74export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 if (typeof field !== "string") return "-";78
79 const dateField = parse(field as string, "yyyy-MM-dd", new Date());80
81 if (!isValid(dateField)) return "-";82
83 const niceDate = format(dateField, "yyyy MMM dd");84 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;85}86
87export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89 if (field !== "Yes" && field !== "No") return "-";90
91 return (92 <div93 className={tw(94 "flex w-full items-center justify-center rounded-lg py-1 font-bold",95 field === "No" && "bg-green-500/10 text-green-600",96 field === "Yes" && "bg-red-500/10 text-red-400",97 )}98 >99 {field}100 </div>101 );102}103
104export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {105 const field = api.columnField(column, row);106 if (typeof field !== "number") return String(field ?? "-");107
108 return (109 <div className="flex justify-center text-yellow-300">110 <StarRating value={field} />111 </div>112 );113}114
115export default function StarRating({ value = 0 }: { value: number }) {116 const uid = useId();117
118 const max = 5;119
120 const clamped = useMemo(() => {121 const n = Number.isFinite(value) ? value : 0;122 return Math.max(0, Math.min(max, n));123 }, [value, max]);124
125 const stars = useMemo(() => {126 return Array.from({ length: max }, (_, i) => {127 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1128 return { i, fill };129 });130 }, [clamped, max]);131
132 return (133 <div className={"inline-flex items-center"} role="img">134 {stars.map(({ i, fill }) => {135 const gradId = `${uid}-star-${i}`;136 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;137 })}138 </div>139 );140}141
142function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {143 const pct = Math.round(fillFraction * 100);144
145 return (146 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">147 <defs>148 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">149 <stop offset={`${pct}%`} stopColor="currentColor" />150 <stop offset={`${pct}%`} stopColor="transparent" />151 </linearGradient>152 </defs>153
154 <path155 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"156 fill={`url(#${gradientId})`}157 />158 {/* Optional outline for crisp edges */}159 <path160 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"161 fill="none"162 stroke="transparent"163 strokeWidth="1"164 opacity="0.35"165 />166 </svg>167 );168}The group function is straightforward and returns an array of strings. Group functions always receive a leaf row, since leaf rows are the rows that are used to create groupings.
1const groupFn: Grid.T.GroupFn<GridSpec["data"]> = (row) => {2 return [row.data.job, row.data.education];3};Non-Uniform Groups
If you inspect the GroupFn type, you will notice that it can return null instead of an
array. When a group function returns null, the row is not grouped and instead sits at the
top level of the view alongside other groups. Furthermore, a grouping function can return
group paths of varying lengths.
The demo below demonstrates a non-uniform group. Notice that any row with "Secondary" education is
not grouped, and any row with Marital status equal to “Single” is only grouped by Job.
Non-Uniform Row Groupings
59 collapsed lines
1import "@1771technologies/lytenyte-pro/light-dark.css";2import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";3import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";4import {5 CountryCell,6 CustomerRating,7 DateCell,8 DurationCell,9 NameCell,10 NumberCell,11 OverdueCell,12} from "./components.js";13import { useState } from "react";14import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";15
16export interface GridSpec {17 readonly data: LoanDataItem;18}19
20const columns: Grid.Column<GridSpec>[] = [21 { name: "Name", id: "name", cellRenderer: NameCell, width: 110 },22 { name: "Country", id: "country", width: 150, cellRenderer: CountryCell },23 { name: "Job", id: "job", width: 120 },24 { name: "Education", id: "education" },25 { name: "Loan Amount", id: "loanAmount", width: 120, type: "number", cellRenderer: NumberCell },26 { name: "Balance", id: "balance", type: "number", cellRenderer: NumberCell },27 { name: "Customer Rating", id: "customerRating", type: "number", width: 125, cellRenderer: CustomerRating },28 { name: "Marital", id: "marital" },29 { name: "Overdue", id: "overdue", cellRenderer: OverdueCell },30 { name: "Duration", id: "duration", type: "number", cellRenderer: DurationCell },31 { name: "Date", id: "date", width: 110, cellRenderer: DateCell },32 { name: "Age", id: "age", width: 80, type: "number" },33 { name: "Contact", id: "contact" },34];35
36const base: Grid.ColumnBase<GridSpec> = { width: 100 };37
38const group: Grid.RowGroupColumn<GridSpec> = {39 cellRenderer: (props) => {40 return (41 <RowGroupCell42 {...props}43 leafLabel={(row, api) => {44 if (!row.parentId) return row.data.education;45
46 const parent = api.rowById(row.parentId);47 if (parent?.kind === "branch" && row.depth === 1) {48 return <div className="ps-6.5 font-bold">{row.data.marital ?? "(blank)"}</div>;49 }50
51 return "";52 }}53 />54 );55 },56 width: 200,57 pin: "start",58};59
60
61const groupFn: Grid.T.GroupFn<GridSpec["data"]> = (row) => {62 if (row.data.education === "Secondary") return null;63
64 if (row.data.marital === "Single") return [row.data.job];65
66 return [row.data.job, row.data.education];67};68
69export default function ClientDemo() {70 const [expansions, setExpansions] = useState<Record<string, boolean | undefined>>({71 Services: true,72 });73
74 const ds = useClientDataSource<GridSpec>({75 data: loanData,76 group: groupFn,77 rowGroupExpansions: expansions,78 onRowGroupExpansionChange: setExpansions,79 });80
81 return (82 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>83 <Grid rowSource={ds} columns={columns} columnBase={base} rowGroupColumn={group} />84 </div>85 );86}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);23
24 return (25 <div26 className={tw(27 "flex h-full w-full items-center justify-end tabular-nums",28 field < 0 && "text-red-600 dark:text-red-300",29 field > 0 && "text-green-600 dark:text-green-300",30 )}31 >32 {formatted}33 </div>34 );35}36
37export function NameCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {38 if (!api.rowIsLeaf(row) || !row.data) return;39
40 const url = row.data?.avatar;41
42 const name = row.data.name;43
44 return (45 <div className="flex h-full w-full items-center gap-2">46 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />47 <div className="text-ln-text-dark flex flex-col gap-0.5">48 <div>{name}</div>49 </div>50 </div>51 );52}53
54export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {55 const field = api.columnField(column, row);56
57 return typeof field === "number" ? `${formatter.format(field)} days` : `${field ?? "-"}`;58}59
60export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const flag = countryFlags[field as keyof typeof countryFlags];64 if (!flag) return "-";65
66 return (67 <div className="flex h-full w-full items-center gap-2">68 <img className="size-4" src={flag} alt={`country flag of ${field}`} />69 <span>{String(field ?? "-")}</span>70 </div>71 );72}73
74export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 if (typeof field !== "string") return "-";78
79 const dateField = parse(field as string, "yyyy-MM-dd", new Date());80
81 if (!isValid(dateField)) return "-";82
83 const niceDate = format(dateField, "yyyy MMM dd");84 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;85}86
87export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89 if (field !== "Yes" && field !== "No") return "-";90
91 return (92 <div93 className={tw(94 "flex w-full items-center justify-center rounded-lg py-1 font-bold",95 field === "No" && "bg-green-500/10 text-green-600",96 field === "Yes" && "bg-red-500/10 text-red-400",97 )}98 >99 {field}100 </div>101 );102}103
104export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {105 const field = api.columnField(column, row);106 if (typeof field !== "number") return String(field ?? "-");107
108 return (109 <div className="flex justify-center text-yellow-300">110 <StarRating value={field} />111 </div>112 );113}114
115export default function StarRating({ value = 0 }: { value: number }) {116 const uid = useId();117
118 const max = 5;119
120 const clamped = useMemo(() => {121 const n = Number.isFinite(value) ? value : 0;122 return Math.max(0, Math.min(max, n));123 }, [value, max]);124
125 const stars = useMemo(() => {126 return Array.from({ length: max }, (_, i) => {127 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1128 return { i, fill };129 });130 }, [clamped, max]);131
132 return (133 <div className={"inline-flex items-center"} role="img">134 {stars.map(({ i, fill }) => {135 const gradId = `${uid}-star-${i}`;136 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;137 })}138 </div>139 );140}141
142function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {143 const pct = Math.round(fillFraction * 100);144
145 return (146 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">147 <defs>148 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">149 <stop offset={`${pct}%`} stopColor="currentColor" />150 <stop offset={`${pct}%`} stopColor="transparent" />151 </linearGradient>152 </defs>153
154 <path155 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"156 fill={`url(#${gradientId})`}157 />158 {/* Optional outline for crisp edges */}159 <path160 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"161 fill="none"162 stroke="transparent"163 strokeWidth="1"164 opacity="0.35"165 />166 </svg>167 );168}Tip
In other data grids, you may have seen non-uniform groups called Tree Data. In LyteNyte Grid, this path-based array of values is simply a non-uniform grouping of rows.
LyteNyte Grid provides a tree data source, but it’s intended for object data, not array data.
Grouping Dimensions
The group property accepts an array of dimensions. A dimension is defined by the following
type interface:
1export type Dimension<T> = { name?: string; field: Field<T> } | { id: string; field?: Field<T> };A dimension is any object with a field or id property. All valid LyteNyte Grid columns conform
to the Dimension type and can be used as dimensions. The demo below shows how to
group rows using dimensions.
Row Group Dimensions
42 collapsed lines
1import "@1771technologies/lytenyte-pro/light-dark.css";2import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";3import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";4import {5 CountryCell,6 CustomerRating,7 DateCell,8 DurationCell,9 NameCell,10 NumberCell,11 OverdueCell,12} from "./components.js";13import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";14
15export interface GridSpec {16 readonly data: LoanDataItem;17}18
19const columns: Grid.Column<GridSpec>[] = [20 { name: "Job", id: "job", width: 120, hide: true },21 { name: "Name", id: "name", cellRenderer: NameCell, width: 110 },22 { name: "Country", id: "country", width: 150, cellRenderer: CountryCell },23 { name: "Loan Amount", id: "loanAmount", width: 120, type: "number", cellRenderer: NumberCell },24 { name: "Balance", id: "balance", type: "number", cellRenderer: NumberCell },25 { name: "Customer Rating", id: "customerRating", type: "number", width: 125, cellRenderer: CustomerRating },26 { name: "Marital", id: "marital" },27 { name: "Education", id: "education", hide: true },28 { name: "Overdue", id: "overdue", cellRenderer: OverdueCell },29 { name: "Duration", id: "duration", type: "number", cellRenderer: DurationCell },30 { name: "Date", id: "date", width: 110, cellRenderer: DateCell },31 { name: "Age", id: "age", width: 80, type: "number" },32 { name: "Contact", id: "contact" },33];34
35const base: Grid.ColumnBase<GridSpec> = { width: 100 };36
37const group: Grid.RowGroupColumn<GridSpec> = {38 cellRenderer: RowGroupCell,39 width: 200,40 pin: "start",41};42
43
44export default function ClientDemo() {45 const ds = useClientDataSource<GridSpec>({46 data: loanData,47 group: [columns[0], { field: "education" }],48 rowGroupDefaultExpansion: true,49 });50
51 return (52 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>53 <Grid rowSource={ds} columns={columns} columnBase={base} rowGroupColumn={group} />54 </div>55 );56}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);23
24 return (25 <div26 className={tw(27 "flex h-full w-full items-center justify-end tabular-nums",28 field < 0 && "text-red-600 dark:text-red-300",29 field > 0 && "text-green-600 dark:text-green-300",30 )}31 >32 {formatted}33 </div>34 );35}36
37export function NameCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {38 if (!api.rowIsLeaf(row) || !row.data) return;39
40 const url = row.data?.avatar;41
42 const name = row.data.name;43
44 return (45 <div className="flex h-full w-full items-center gap-2">46 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />47 <div className="text-ln-text-dark flex flex-col gap-0.5">48 <div>{name}</div>49 </div>50 </div>51 );52}53
54export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {55 const field = api.columnField(column, row);56
57 return typeof field === "number" ? `${formatter.format(field)} days` : `${field ?? "-"}`;58}59
60export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const flag = countryFlags[field as keyof typeof countryFlags];64 if (!flag) return "-";65
66 return (67 <div className="flex h-full w-full items-center gap-2">68 <img className="size-4" src={flag} alt={`country flag of ${field}`} />69 <span>{String(field ?? "-")}</span>70 </div>71 );72}73
74export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 if (typeof field !== "string") return "-";78
79 const dateField = parse(field as string, "yyyy-MM-dd", new Date());80
81 if (!isValid(dateField)) return "-";82
83 const niceDate = format(dateField, "yyyy MMM dd");84 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;85}86
87export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89 if (field !== "Yes" && field !== "No") return "-";90
91 return (92 <div93 className={tw(94 "flex w-full items-center justify-center rounded-lg py-1 font-bold",95 field === "No" && "bg-green-500/10 text-green-600",96 field === "Yes" && "bg-red-500/10 text-red-400",97 )}98 >99 {field}100 </div>101 );102}103
104export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {105 const field = api.columnField(column, row);106 if (typeof field !== "number") return String(field ?? "-");107
108 return (109 <div className="flex justify-center text-yellow-300">110 <StarRating value={field} />111 </div>112 );113}114
115export default function StarRating({ value = 0 }: { value: number }) {116 const uid = useId();117
118 const max = 5;119
120 const clamped = useMemo(() => {121 const n = Number.isFinite(value) ? value : 0;122 return Math.max(0, Math.min(max, n));123 }, [value, max]);124
125 const stars = useMemo(() => {126 return Array.from({ length: max }, (_, i) => {127 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1128 return { i, fill };129 });130 }, [clamped, max]);131
132 return (133 <div className={"inline-flex items-center"} role="img">134 {stars.map(({ i, fill }) => {135 const gradId = `${uid}-star-${i}`;136 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;137 })}138 </div>139 );140}141
142function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {143 const pct = Math.round(fillFraction * 100);144
145 return (146 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">147 <defs>148 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">149 <stop offset={`${pct}%`} stopColor="currentColor" />150 <stop offset={`${pct}%`} stopColor="transparent" />151 </linearGradient>152 </defs>153
154 <path155 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"156 fill={`url(#${gradientId})`}157 />158 {/* Optional outline for crisp edges */}159 <path160 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"161 fill="none"162 stroke="transparent"163 strokeWidth="1"164 opacity="0.35"165 />166 </svg>167 );168}Row grouping using dimensions is easier to manage since you can directly use the columns you pass to the grid. However, dimensions always result in uniform groups.
Changing Row Groups
Update row groups by changing the group property value. The demo below updates
groups using the Pill Manager component.
Change Row Group
44 collapsed lines
1import "@1771technologies/lytenyte-pro/pill-manager.css";2import "@1771technologies/lytenyte-pro/light-dark.css";3import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";4import { useMemo, useState } from "react";5import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";6import {7 CountryCell,8 CustomerRating,9 DateCell,10 DurationCell,11 NameCell,12 NumberCell,13 OverdueCell,14} from "./components.js";15import { PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";16
17export interface GridSpec {18 readonly data: LoanDataItem;19}20
21const columns: Grid.Column<GridSpec>[] = [22 { name: "Name", id: "name", cellRenderer: NameCell, width: 110 },23 { name: "Country", id: "country", width: 150, cellRenderer: CountryCell },24 { name: "Loan Amount", id: "loanAmount", width: 120, type: "number", cellRenderer: NumberCell },25 { name: "Balance", id: "balance", type: "number", cellRenderer: NumberCell },26 { name: "Customer Rating", id: "customerRating", type: "number", width: 125, cellRenderer: CustomerRating },27 { name: "Marital", id: "marital" },28 { name: "Education", id: "education" },29 { name: "Job", id: "job", width: 120 },30 { name: "Overdue", id: "overdue", cellRenderer: OverdueCell },31 { name: "Duration", id: "duration", type: "number", cellRenderer: DurationCell },32 { name: "Date", id: "date", width: 110, cellRenderer: DateCell },33 { name: "Age", id: "age", width: 80, type: "number" },34 { name: "Contact", id: "contact" },35];36
37const base: Grid.ColumnBase<GridSpec> = { width: 100 };38
39const group: Grid.RowGroupColumn<GridSpec> = {40 cellRenderer: RowGroupCell,41 width: 200,42 pin: "start",43};44
45
46export default function ClientDemo() {47 const [rowGroups, setRowGroups] = useState<PillManager.T.PillItem[]>([48 { name: "Job", id: "job", active: true, movable: true },49 { name: "Education", id: "education", active: true, movable: true },50 { name: "Marital", id: "marital", active: false, movable: true },51 { name: "Contact", id: "contact", active: false, movable: true },52 ]);53
54 const columnsWithHide = useMemo(() => {55 return columns.map((x) => {56 if (rowGroups.find((g) => g.id === x.id && g.active)) {57 return { ...x, hide: true };58 }59 return x;60 });61 }, [rowGroups]);62
63 const ds = useClientDataSource<GridSpec>({64 data: loanData,65 group: useMemo(() => rowGroups.filter((x) => x.active), [rowGroups]),66 rowGroupDefaultExpansion: true,67 });68
69 return (70 <>71 <div>72 <PillManager73 onPillItemActiveChange={(p) => {74 setRowGroups((prev) => {75 return [...prev].map((x) => {76 if (p.item.id === x.id) {77 return { ...x, active: p.item.active };78 }79 return x;80 });81 });82 }}83 onPillRowChange={(ev) => {84 setRowGroups(ev.changed[0].pills);85 }}86 rows={[87 {88 id: "row-groups",89 label: "Row Groups",90 type: "row-groups",91 pills: rowGroups,92 },93 ]}94 />95 </div>96 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>97 <Grid rowSource={ds} columns={columnsWithHide} columnBase={base} rowGroupColumn={group} />98 </div>99 </>100 );101}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);23
24 return (25 <div26 className={tw(27 "flex h-full w-full items-center justify-end tabular-nums",28 field < 0 && "text-red-600 dark:text-red-300",29 field > 0 && "text-green-600 dark:text-green-300",30 )}31 >32 {formatted}33 </div>34 );35}36
37export function NameCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {38 if (!api.rowIsLeaf(row) || !row.data) return;39
40 const url = row.data?.avatar;41
42 const name = row.data.name;43
44 return (45 <div className="flex h-full w-full items-center gap-2">46 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />47 <div className="text-ln-text-dark flex flex-col gap-0.5">48 <div>{name}</div>49 </div>50 </div>51 );52}53
54export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {55 const field = api.columnField(column, row);56
57 return typeof field === "number" ? `${formatter.format(field)} days` : `${field ?? "-"}`;58}59
60export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const flag = countryFlags[field as keyof typeof countryFlags];64 if (!flag) return "-";65
66 return (67 <div className="flex h-full w-full items-center gap-2">68 <img className="size-4" src={flag} alt={`country flag of ${field}`} />69 <span>{String(field ?? "-")}</span>70 </div>71 );72}73
74export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 if (typeof field !== "string") return "-";78
79 const dateField = parse(field as string, "yyyy-MM-dd", new Date());80
81 if (!isValid(dateField)) return "-";82
83 const niceDate = format(dateField, "yyyy MMM dd");84 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;85}86
87export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89 if (field !== "Yes" && field !== "No") return "-";90
91 return (92 <div93 className={tw(94 "flex w-full items-center justify-center rounded-lg py-1 font-bold",95 field === "No" && "bg-green-500/10 text-green-600",96 field === "Yes" && "bg-red-500/10 text-red-400",97 )}98 >99 {field}100 </div>101 );102}103
104export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {105 const field = api.columnField(column, row);106 if (typeof field !== "number") return String(field ?? "-");107
108 return (109 <div className="flex justify-center text-yellow-300">110 <StarRating value={field} />111 </div>112 );113}114
115export default function StarRating({ value = 0 }: { value: number }) {116 const uid = useId();117
118 const max = 5;119
120 const clamped = useMemo(() => {121 const n = Number.isFinite(value) ? value : 0;122 return Math.max(0, Math.min(max, n));123 }, [value, max]);124
125 const stars = useMemo(() => {126 return Array.from({ length: max }, (_, i) => {127 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1128 return { i, fill };129 });130 }, [clamped, max]);131
132 return (133 <div className={"inline-flex items-center"} role="img">134 {stars.map(({ i, fill }) => {135 const gradId = `${uid}-star-${i}`;136 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;137 })}138 </div>139 );140}141
142function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {143 const pct = Math.round(fillFraction * 100);144
145 return (146 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">147 <defs>148 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">149 <stop offset={`${pct}%`} stopColor="currentColor" />150 <stop offset={`${pct}%`} stopColor="transparent" />151 </linearGradient>152 </defs>153
154 <path155 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"156 fill={`url(#${gradientId})`}157 />158 {/* Optional outline for crisp edges */}159 <path160 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"161 fill="none"162 stroke="transparent"163 strokeWidth="1"164 opacity="0.35"165 />166 </svg>167 );168}Tip
It is generally good practice to hide columns that you group on. In the demo, the code updates the columns based on the current group state before passing them to the grid. This way, when a column is grouped the grid hides the column, and when the column is ungrouped the grid shows the column again. The code snippet is shown below:
1const columnsWithHide = useMemo(() => {2 return columns.map((x) => {3 if (rowGroups.find((g) => g.id === x.id && g.active)) {4 return { ...x, hide: true };5 }6 return x;7 });8}, [rowGroups]);Row Group Expansions
The row data source maintains the expansion state of row groups in LyteNyte Grid. For the client data source,
expansions can be controlled or uncontrolled. To control expansions, pass a rowGroupExpansions value to the
useClientDataSource hook. To handle expansion changes, provide an onRowGroupExpansionChange callback on the
row source.
LyteNyte Grid provides the api.rowGroupToggle method to change the expansion state of a row group. This
method calls onRowGroupExpansionChange with the delta changes. The rowGroupDefaultExpansion
setting on the client source or grid API updates the expansion state and calls onRowGroupExpansionChange,
if provided, on the row data source.
Using api.rowGroupToggle, you can create your own group cell renderer to
expand and collapse rows. As shown in the demo below.
Row Group Expansion
36 collapsed lines
1import "@1771technologies/lytenyte-pro/light-dark.css";2import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";3import { useState } from "react";4import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";5import {6 CountryCell,7 CustomerRating,8 DateCell,9 DurationCell,10 NameCell,11 NumberCell,12 OverdueCell,13} from "./components.js";14
15export interface GridSpec {16 readonly data: LoanDataItem;17}18
19const columns: Grid.Column<GridSpec>[] = [20 { name: "Name", id: "name", cellRenderer: NameCell, width: 110 },21 { name: "Country", id: "country", width: 150, cellRenderer: CountryCell },22 { name: "Loan Amount", id: "loanAmount", width: 120, type: "number", cellRenderer: NumberCell },23 { name: "Balance", id: "balance", type: "number", cellRenderer: NumberCell },24 { name: "Customer Rating", id: "customerRating", type: "number", width: 125, cellRenderer: CustomerRating },25 { name: "Marital", id: "marital" },26 { name: "Education", id: "education", hide: true },27 { name: "Job", id: "job", width: 120, hide: true },28 { name: "Overdue", id: "overdue", cellRenderer: OverdueCell },29 { name: "Duration", id: "duration", type: "number", cellRenderer: DurationCell },30 { name: "Date", id: "date", width: 110, cellRenderer: DateCell },31 { name: "Age", id: "age", width: 80, type: "number" },32 { name: "Contact", id: "contact" },33];34
35const base: Grid.ColumnBase<GridSpec> = { width: 100 };36
37
38const group: Grid.RowGroupColumn<GridSpec> = {39 cellRenderer: ({ row, api }) => {40 if (!api.rowIsGroup(row)) return "";41
42 return (43 <div className="flex h-full w-full items-center gap-2" style={{ paddingInlineStart: 16 * row.depth }}>44 <button45 className="size-5 cursor-pointer"46 style={{ transform: row.expanded ? "rotate(90deg)" : undefined }}47 onClick={() => api.rowGroupToggle(row)}48 >49 <CaretRight />50 </button>51
52 <div>{row.key}</div>53 </div>54 );55 },56 width: 200,57 pin: "start",58};59
60const groupFn: Grid.T.GroupFn<GridSpec["data"]> = (row) => {61 return [row.data.job, row.data.education];62};63
64export default function ClientDemo() {65 const [expansions, setExpansions] = useState<Record<string, boolean | undefined>>({66 Administration: true,67 "Administration->Primary": true,68 });69 const ds = useClientDataSource<GridSpec>({70 data: loanData,71 group: groupFn,72 rowGroupExpansions: expansions,73 onRowGroupExpansionChange: setExpansions,74 });75
76 return (77 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>78 <Grid rowSource={ds} columns={columns} columnBase={base} rowGroupColumn={group} />79 </div>80 );81}82
83function CaretRight() {84 return (85 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">86 <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>87 </svg>88 );89}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);23
24 return (25 <div26 className={tw(27 "flex h-full w-full items-center justify-end tabular-nums",28 field < 0 && "text-red-600 dark:text-red-300",29 field > 0 && "text-green-600 dark:text-green-300",30 )}31 >32 {formatted}33 </div>34 );35}36
37export function NameCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {38 if (!api.rowIsLeaf(row) || !row.data) return;39
40 const url = row.data?.avatar;41
42 const name = row.data.name;43
44 return (45 <div className="flex h-full w-full items-center gap-2">46 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />47 <div className="text-ln-text-dark flex flex-col gap-0.5">48 <div>{name}</div>49 </div>50 </div>51 );52}53
54export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {55 const field = api.columnField(column, row);56
57 return typeof field === "number" ? `${formatter.format(field)} days` : `${field ?? "-"}`;58}59
60export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const flag = countryFlags[field as keyof typeof countryFlags];64 if (!flag) return "-";65
66 return (67 <div className="flex h-full w-full items-center gap-2">68 <img className="size-4" src={flag} alt={`country flag of ${field}`} />69 <span>{String(field ?? "-")}</span>70 </div>71 );72}73
74export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 if (typeof field !== "string") return "-";78
79 const dateField = parse(field as string, "yyyy-MM-dd", new Date());80
81 if (!isValid(dateField)) return "-";82
83 const niceDate = format(dateField, "yyyy MMM dd");84 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;85}86
87export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89 if (field !== "Yes" && field !== "No") return "-";90
91 return (92 <div93 className={tw(94 "flex w-full items-center justify-center rounded-lg py-1 font-bold",95 field === "No" && "bg-green-500/10 text-green-600",96 field === "Yes" && "bg-red-500/10 text-red-400",97 )}98 >99 {field}100 </div>101 );102}103
104export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {105 const field = api.columnField(column, row);106 if (typeof field !== "number") return String(field ?? "-");107
108 return (109 <div className="flex justify-center text-yellow-300">110 <StarRating value={field} />111 </div>112 );113}114
115export default function StarRating({ value = 0 }: { value: number }) {116 const uid = useId();117
118 const max = 5;119
120 const clamped = useMemo(() => {121 const n = Number.isFinite(value) ? value : 0;122 return Math.max(0, Math.min(max, n));123 }, [value, max]);124
125 const stars = useMemo(() => {126 return Array.from({ length: max }, (_, i) => {127 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1128 return { i, fill };129 });130 }, [clamped, max]);131
132 return (133 <div className={"inline-flex items-center"} role="img">134 {stars.map(({ i, fill }) => {135 const gradId = `${uid}-star-${i}`;136 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;137 })}138 </div>139 );140}141
142function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {143 const pct = Math.round(fillFraction * 100);144
145 return (146 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">147 <defs>148 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">149 <stop offset={`${pct}%`} stopColor="currentColor" />150 <stop offset={`${pct}%`} stopColor="transparent" />151 </linearGradient>152 </defs>153
154 <path155 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"156 fill={`url(#${gradientId})`}157 />158 {/* Optional outline for crisp edges */}159 <path160 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"161 fill="none"162 stroke="transparent"163 strokeWidth="1"164 opacity="0.35"165 />166 </svg>167 );168}Default Expansions
The rowGroupDefaultExpansion property on the client data source controls the default
expansion state for any row group that does not have a value in rowGroupExpansions.
The rowGroupDefaultExpansion property accepts one of two values:
- A boolean, where
falsecollapses all rows by default andtrueexpands all rows by default. - A positive number, which expands all row groups with a depth value less than or equal to the provided number by default.
The demo below demonstrates expanding the first level of row groups by setting the
rowGroupDefaultExpansion value to 0.
Default Row Group Expansion
36 collapsed lines
1import "@1771technologies/lytenyte-pro/light-dark.css";2import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";3import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";4import {5 CountryCell,6 CustomerRating,7 DateCell,8 DurationCell,9 NameCell,10 NumberCell,11 OverdueCell,12} from "./components.js";13import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";14
15export interface GridSpec {16 readonly data: LoanDataItem;17}18
19const columns: Grid.Column<GridSpec>[] = [20 { name: "Name", id: "name", cellRenderer: NameCell, width: 110 },21 { name: "Country", id: "country", width: 150, cellRenderer: CountryCell },22 { name: "Loan Amount", id: "loanAmount", width: 120, type: "number", cellRenderer: NumberCell },23 { name: "Balance", id: "balance", type: "number", cellRenderer: NumberCell },24 { name: "Customer Rating", id: "customerRating", type: "number", width: 125, cellRenderer: CustomerRating },25 { name: "Marital", id: "marital" },26 { name: "Education", id: "education", hide: true },27 { name: "Job", id: "job", width: 120, hide: true },28 { name: "Overdue", id: "overdue", cellRenderer: OverdueCell },29 { name: "Duration", id: "duration", type: "number", cellRenderer: DurationCell },30 { name: "Date", id: "date", width: 110, cellRenderer: DateCell },31 { name: "Age", id: "age", width: 80, type: "number" },32 { name: "Contact", id: "contact" },33];34
35const base: Grid.ColumnBase<GridSpec> = { width: 100 };36
37
38const group: Grid.RowGroupColumn<GridSpec> = {39 cellRenderer: RowGroupCell,40 width: 200,41 pin: "start",42};43
44const groupFn: Grid.T.GroupFn<GridSpec["data"]> = (row) => {45 return [row.data.job, row.data.education];46};47
48export default function ClientDemo() {49 const ds = useClientDataSource<GridSpec>({50 data: loanData,51 group: groupFn,52 rowGroupDefaultExpansion: 0,53 });54
55 return (56 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>57 <Grid rowSource={ds} columns={columns} columnBase={base} rowGroupColumn={group} />58 </div>59 );60}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);23
24 return (25 <div26 className={tw(27 "flex h-full w-full items-center justify-end tabular-nums",28 field < 0 && "text-red-600 dark:text-red-300",29 field > 0 && "text-green-600 dark:text-green-300",30 )}31 >32 {formatted}33 </div>34 );35}36
37export function NameCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {38 if (!api.rowIsLeaf(row) || !row.data) return;39
40 const url = row.data?.avatar;41
42 const name = row.data.name;43
44 return (45 <div className="flex h-full w-full items-center gap-2">46 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />47 <div className="text-ln-text-dark flex flex-col gap-0.5">48 <div>{name}</div>49 </div>50 </div>51 );52}53
54export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {55 const field = api.columnField(column, row);56
57 return typeof field === "number" ? `${formatter.format(field)} days` : `${field ?? "-"}`;58}59
60export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const flag = countryFlags[field as keyof typeof countryFlags];64 if (!flag) return "-";65
66 return (67 <div className="flex h-full w-full items-center gap-2">68 <img className="size-4" src={flag} alt={`country flag of ${field}`} />69 <span>{String(field ?? "-")}</span>70 </div>71 );72}73
74export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 if (typeof field !== "string") return "-";78
79 const dateField = parse(field as string, "yyyy-MM-dd", new Date());80
81 if (!isValid(dateField)) return "-";82
83 const niceDate = format(dateField, "yyyy MMM dd");84 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;85}86
87export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89 if (field !== "Yes" && field !== "No") return "-";90
91 return (92 <div93 className={tw(94 "flex w-full items-center justify-center rounded-lg py-1 font-bold",95 field === "No" && "bg-green-500/10 text-green-600",96 field === "Yes" && "bg-red-500/10 text-red-400",97 )}98 >99 {field}100 </div>101 );102}103
104export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {105 const field = api.columnField(column, row);106 if (typeof field !== "number") return String(field ?? "-");107
108 return (109 <div className="flex justify-center text-yellow-300">110 <StarRating value={field} />111 </div>112 );113}114
115export default function StarRating({ value = 0 }: { value: number }) {116 const uid = useId();117
118 const max = 5;119
120 const clamped = useMemo(() => {121 const n = Number.isFinite(value) ? value : 0;122 return Math.max(0, Math.min(max, n));123 }, [value, max]);124
125 const stars = useMemo(() => {126 return Array.from({ length: max }, (_, i) => {127 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1128 return { i, fill };129 });130 }, [clamped, max]);131
132 return (133 <div className={"inline-flex items-center"} role="img">134 {stars.map(({ i, fill }) => {135 const gradId = `${uid}-star-${i}`;136 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;137 })}138 </div>139 );140}141
142function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {143 const pct = Math.round(fillFraction * 100);144
145 return (146 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">147 <defs>148 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">149 <stop offset={`${pct}%`} stopColor="currentColor" />150 <stop offset={`${pct}%`} stopColor="transparent" />151 </linearGradient>152 </defs>153
154 <path155 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"156 fill={`url(#${gradientId})`}157 />158 {/* Optional outline for crisp edges */}159 <path160 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"161 fill="none"162 stroke="transparent"163 strokeWidth="1"164 opacity="0.35"165 />166 </svg>167 );168}Expand Siblings
The client row source provides utility methods for querying the structure of the row hierarchy.
Using the rowSiblings method, you can build the ability to expand sibling nodes
of a group. This method is available on the row source and the grid API. Combine the rowSiblings method
with onRowGroupExpansionChange to build this functionality.
The demo below demonstrates this functionality. Click the + button instead of the expansion chevron to expand
all sibling rows for the current group.
Sibling Row Group Expansion
37 collapsed lines
1import "@1771technologies/lytenyte-pro/light-dark.css";2import { Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";3import { MinusCircledIcon, PlusCircledIcon } from "@radix-ui/react-icons";4import { loanData, type LoanDataItem } from "@1771technologies/grid-sample-data/loan-data";5import {6 CountryCell,7 CustomerRating,8 DateCell,9 DurationCell,10 NameCell,11 NumberCell,12 OverdueCell,13} from "./components.js";14import { useState } from "react";15
16export interface GridSpec {17 readonly data: LoanDataItem;18}19
20const columns: Grid.Column<GridSpec>[] = [21 { name: "Name", id: "name", cellRenderer: NameCell, width: 110 },22 { name: "Country", id: "country", width: 150, cellRenderer: CountryCell },23 { name: "Loan Amount", id: "loanAmount", width: 120, type: "number", cellRenderer: NumberCell },24 { name: "Balance", id: "balance", type: "number", cellRenderer: NumberCell },25 { name: "Customer Rating", id: "customerRating", type: "number", width: 125, cellRenderer: CustomerRating },26 { name: "Marital", id: "marital" },27 { name: "Education", id: "education", hide: true },28 { name: "Job", id: "job", width: 120, hide: true },29 { name: "Overdue", id: "overdue", cellRenderer: OverdueCell },30 { name: "Duration", id: "duration", type: "number", cellRenderer: DurationCell },31 { name: "Date", id: "date", width: 110, cellRenderer: DateCell },32 { name: "Age", id: "age", width: 80, type: "number" },33 { name: "Contact", id: "contact" },34];35
36const base: Grid.ColumnBase<GridSpec> = { width: 100 };37
38
39const group: Grid.RowGroupColumn<GridSpec> = {40 cellRenderer: ({ row, api }) => {41 if (!api.rowIsGroup(row)) return "";42
43 return (44 <div className="flex h-full w-full items-center gap-2" style={{ paddingInlineStart: 16 * row.depth }}>45 <button46 className="size-5 cursor-pointer"47 onClick={() => {48 const siblings = api.rowSiblings(row.id);49 const update = Object.fromEntries(siblings.map((x) => [x, !row.expanded]));50 api.rowGroupExpansionChange(update);51 }}52 >53 {row.expanded && <MinusCircledIcon />}54 {!row.expanded && <PlusCircledIcon />}55 </button>56 <button57 className="size-5 cursor-pointer"58 style={{ transform: row.expanded ? "rotate(90deg)" : undefined }}59 onClick={() => api.rowGroupToggle(row)}60 >61 <CaretRight />62 </button>63
64 <div>{row.key}</div>65 </div>66 );67 },68 width: 200,69 pin: "start",70};71
72const groupFn: Grid.T.GroupFn<GridSpec["data"]> = (row) => {73 return [row.data.job, row.data.education];74};75
76export default function ClientDemo() {77 const [expansions, setExpansions] = useState<Record<string, boolean | undefined>>({78 Administration: true,79 "Administration->Primary": true,80 });81
82 const ds = useClientDataSource<GridSpec>({83 data: loanData,84 group: groupFn,85 rowGroupExpansions: expansions,86 onRowGroupExpansionChange: setExpansions,87 });88
89 return (90 <div className="ln-grid ln-header:data-[ln-colid=overdue]:justify-center" style={{ height: 500 }}>91 <Grid rowSource={ds} columns={columns} columnBase={base} rowGroupColumn={group} />92 </div>93 );94}95
96function CaretRight() {97 return (98 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">99 <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>100 </svg>101 );102}1import { type Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo.js";3import { twMerge } from "tailwind-merge";4import clsx, { type ClassValue } from "clsx";5import { countryFlags } from "@1771technologies/grid-sample-data/loan-data";6import { useId, useMemo } from "react";7import { format, isValid, parse } from "date-fns";8
9export function tw(...c: ClassValue[]) {10 return twMerge(clsx(...c));11}12
13const formatter = new Intl.NumberFormat("en-US", {14 maximumFractionDigits: 2,15 minimumFractionDigits: 0,16});17export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {18 const field = api.columnField(column, row);19
20 if (typeof field !== "number") return "-";21
22 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);23
24 return (25 <div26 className={tw(27 "flex h-full w-full items-center justify-end tabular-nums",28 field < 0 && "text-red-600 dark:text-red-300",29 field > 0 && "text-green-600 dark:text-green-300",30 )}31 >32 {formatted}33 </div>34 );35}36
37export function NameCell({ api, row }: Grid.T.CellRendererParams<GridSpec>) {38 if (!api.rowIsLeaf(row) || !row.data) return;39
40 const url = row.data?.avatar;41
42 const name = row.data.name;43
44 return (45 <div className="flex h-full w-full items-center gap-2">46 <img className="border-ln-border-strong h-7 w-7 rounded-full border" src={url} alt={name} />47 <div className="text-ln-text-dark flex flex-col gap-0.5">48 <div>{name}</div>49 </div>50 </div>51 );52}53
54export function DurationCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {55 const field = api.columnField(column, row);56
57 return typeof field === "number" ? `${formatter.format(field)} days` : `${field ?? "-"}`;58}59
60export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {61 const field = api.columnField(column, row);62
63 const flag = countryFlags[field as keyof typeof countryFlags];64 if (!flag) return "-";65
66 return (67 <div className="flex h-full w-full items-center gap-2">68 <img className="size-4" src={flag} alt={`country flag of ${field}`} />69 <span>{String(field ?? "-")}</span>70 </div>71 );72}73
74export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {75 const field = api.columnField(column, row);76
77 if (typeof field !== "string") return "-";78
79 const dateField = parse(field as string, "yyyy-MM-dd", new Date());80
81 if (!isValid(dateField)) return "-";82
83 const niceDate = format(dateField, "yyyy MMM dd");84 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;85}86
87export function OverdueCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {88 const field = api.columnField(column, row);89 if (field !== "Yes" && field !== "No") return "-";90
91 return (92 <div93 className={tw(94 "flex w-full items-center justify-center rounded-lg py-1 font-bold",95 field === "No" && "bg-green-500/10 text-green-600",96 field === "Yes" && "bg-red-500/10 text-red-400",97 )}98 >99 {field}100 </div>101 );102}103
104export function CustomerRating({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {105 const field = api.columnField(column, row);106 if (typeof field !== "number") return String(field ?? "-");107
108 return (109 <div className="flex justify-center text-yellow-300">110 <StarRating value={field} />111 </div>112 );113}114
115export default function StarRating({ value = 0 }: { value: number }) {116 const uid = useId();117
118 const max = 5;119
120 const clamped = useMemo(() => {121 const n = Number.isFinite(value) ? value : 0;122 return Math.max(0, Math.min(max, n));123 }, [value, max]);124
125 const stars = useMemo(() => {126 return Array.from({ length: max }, (_, i) => {127 const fill = Math.max(0, Math.min(1, clamped - i)); // 0..1128 return { i, fill };129 });130 }, [clamped, max]);131
132 return (133 <div className={"inline-flex items-center"} role="img">134 {stars.map(({ i, fill }) => {135 const gradId = `${uid}-star-${i}`;136 return <StarIcon key={i} fillFraction={fill} gradientId={gradId} />;137 })}138 </div>139 );140}141
142function StarIcon({ fillFraction, gradientId }: { fillFraction: number; gradientId: string }) {143 const pct = Math.round(fillFraction * 100);144
145 return (146 <svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">147 <defs>148 <linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">149 <stop offset={`${pct}%`} stopColor="currentColor" />150 <stop offset={`${pct}%`} stopColor="transparent" />151 </linearGradient>152 </defs>153
154 <path155 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"156 fill={`url(#${gradientId})`}157 />158 {/* Optional outline for crisp edges */}159 <path160 d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"161 fill="none"162 stroke="transparent"163 strokeWidth="1"164 opacity="0.35"165 />166 </svg>167 );168}Next Steps
- Client Row Aggregations: Aggregate row data per group to display values at the group level.
- Client Row Having Filters: Use client-side filters to exclude rows after row grouping.
- Client Row Label Filters: Use row label filters to show only row groups with valid grouping labels.
- Client Row Sorting: Sort rows in ascending or descending order with the client row source.
