Row & Column Pivots
Configure pivot row and column fields in the client data source to create specific views of your data.
Note
To focus on row and column configuration, all demos in this guide use a single measure column: sum of Profit. For measure configuration, see the Measures guide.
All demos also use the Pill Manager to manage pivot configuration. This component is optional; you can substitute any UI that fits your application.
Pivot Columns
Set the columns property on the pivotModel of the client data source to configure
which columns are used to dynamically generate pivot columns. The values in the
columns property are used to create the pivot column definitions.
The demo below lets you select pivot columns. Try different column combinations to see how pivoting behaves.
Column Pivots
1import "@1771technologies/lytenyte-pro/pill-manager.css";2import "@1771technologies/lytenyte-pro/components.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";5import { computeField, Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";6import {7 AgeGroup,8 CostCell,9 CountryCell,10 DateCell,11 GenderCell,12 NumberCell,13 ProfitCell,14 StickGroupHeader,15 style,16} from "./components.jsx";17import { sum } from "es-toolkit";18import { useMemo, useState } from "react";19import { PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";20
21export interface GridSpec {22 readonly data: SaleDataItem;23 readonly column: {24 pivotable?: boolean;25 };26}27
28export const columns: Grid.Column<GridSpec>[] = [29 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },30 { id: "age", name: "Age", type: "number", width: 80 },31 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110, pivotable: true },32 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80, pivotable: true },33 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150, pivotable: true },34 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },35 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },36 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },37 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },38 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },39 { id: "state", name: "State", width: 150, pivotable: true },40 { id: "product", name: "Product", width: 160, pivotable: true },41 { id: "productCategory", name: "Category", width: 120, pivotable: true },42 { id: "subCategory", name: "Sub-Category", width: 160, pivotable: true },43];44
45const base: Grid.ColumnBase<GridSpec> = { width: 120, resizable: true };46
47const group: Grid.RowGroupColumn<GridSpec> = {48 cellRenderer: RowGroupCell,49 width: 200,50 pin: "start",51};52
53const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {54 const values = data.map((x) => computeField<number>(field, x));55 return sum(values);56};57
58export default function PivotDemo() {59 const [pivots, setPivots] = useState<PillManager.T.PillItem[]>(() =>60 columns61 .filter((x) => x.pivotable)62 .map((x) => ({ id: x.id, name: x.name ?? x.id, active: x.id === "ageGroup" })),63 );64
65 const ds = useClientDataSource<GridSpec>({66 data: salesData,67 pivotMode: true,68 pivotModel: {69 columns: pivots.filter((x) => x.active),70 measures: [71 {72 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },73 fn: "sum",74 },75 ],76 },77 aggregateFns: { sum: aggSum },78 });79
80 const pillRows = useMemo(() => {81 const pivotPills = pivots.map((x) => {82 return {83 id: x.id,84 active: x.active,85 movable: x.active,86 name: x.name ?? x.id,87 data: x,88 removable: true,89 };90 });91 return [{ id: "column-pivots", label: "Column Pivots", type: "column-pivots", pills: pivotPills }];92 }, [pivots]);93
94 const pivotProps = ds.usePivotProps();95
96 const [groupColumn, setGroupColumn] = useState(group);97
98 return (99 <>100 <div>101 <PillManager102 rows={pillRows}103 onPillItemActiveChange={(p) => {104 setPivots((prev) => {105 const next = prev.map((x) =>106 x.id === p.item.id ? { ...x, active: p.item.active && p.row.id === "column-pivots" } : x,107 );108
109 return [...next.filter((x) => x.active), ...next.filter((x) => !x.active)];110 });111 }}112 onPillRowChange={(ev) => {113 const activeFirst = ev.changed[0].pills.filter((x) => x.active);114 const nonActive = ev.changed[0].pills.filter((x) => !x.active);115 setPivots([...activeFirst, ...nonActive]);116 }}117 />118 </div>119 <div className="ln-grid" style={{ height: 500 }}>120 <Grid121 columns={columns}122 rowSource={ds}123 columnBase={base}124 rowGroupColumn={groupColumn}125 onRowGroupColumnChange={setGroupColumn}126 {...pivotProps}127 styles={style}128 columnGroupRenderer={StickGroupHeader}129 />130 </div>131 </>132 );133}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7
8// Need to set header group overflow to unset to allow sticky header labels.9export const style: Grid.Style = { headerGroup: { style: { overflow: "unset" } } };10export function StickGroupHeader(props: Grid.T.HeaderGroupParams<GridSpec>) {11 return (12 <div style={{ position: "sticky", insetInlineStart: "calc(var(--ln-start-offset) + 8px)" }}>13 {props.groupPath.at(-1)}14 </div>15 );16}17
18export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {19 const field = api.columnField(column, row);20
21 if (typeof field !== "string") return "-";22
23 const dateField = parse(field as string, "MM/dd/yyyy", new Date());24
25 if (!isValid(dateField)) return "-";26
27 const niceDate = format(dateField, "yyyy MMM dd");28 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;29}30
31export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {32 const field = column.name ?? column.id;33
34 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;35 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;36 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;37
38 if (field === "Grand Total") return "Grand Total";39
40 return "Other";41}42
43export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;47 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;48 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;49
50 return "-";51}52
53export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {54 const field = api.columnField(column, row);55
56 if (field === "Male")57 return (58 <div className="flex h-full w-full items-center gap-2">59 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">60 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">61 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>62 </svg>63 </div>64 Male65 </div>66 );67
68 if (field === "Female")69 return (70 <div className="flex h-full w-full items-center gap-2">71 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">72 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">73 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>74 </svg>75 </div>76 Female77 </div>78 );79
80 return "-";81}82
83function tw(...c: ClassValue[]) {84 return twMerge(clsx(...c));85}86
87const formatter = new Intl.NumberFormat("en-US", {88 maximumFractionDigits: 2,89 minimumFractionDigits: 0,90});91export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {92 const field = api.columnField(column, row);93
94 if (typeof field !== "number") return "-";95
96 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);97
98 return (99 <div100 className={tw(101 "flex h-full w-full items-center justify-end tabular-nums",102 field < 0 && "text-red-600 dark:text-red-300",103 field > 0 && "text-green-600 dark:text-green-300",104 )}105 >106 {formatted}107 </div>108 );109}110
111export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {112 const field = api.columnField(column, row);113
114 if (typeof field !== "number") return "-";115
116 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);117
118 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;119}120
121export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {122 const field = api.columnField(column, row);123
124 if (typeof field !== "number") return "-";125
126 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);127
128 return (129 <div130 className={tw(131 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",132 )}133 >134 {formatted}135 </div>136 );137}138
139export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {140 const field = api.columnField(column, row);141
142 const flag = countryFlags[field as keyof typeof countryFlags];143 if (!flag) return "-";144
145 return (146 <div className="flex h-full w-full items-center gap-2">147 <img className="size-4" src={flag} alt={`country flag of ${field}`} />148 <span>{String(field ?? "-")}</span>149 </div>150 );151}Each pivot you add deepens the column hierarchy, and the number of generated columns can grow exponentially. LyteNyte Grid imposes no limits on the number of pivots you can apply, so your application should limit this based on your data requirements.
Without pivot rows, LyteNyte Grid aggregates all rows into a single totals row. See the Pivot Rows section of this guide to add pivot rows.
Pivot Column Group Expansions
Defining multiple pivot columns creates a column group hierarchy. Since the grid generates these dynamically, they are not explicitly defined in your application state.
The client data source manages column group expansion state internally. You can
collapse column groups using the grid’s
api.columnToggleGroup method.
The demo below shows this behavior. The grid is pivoted on Age Group and Gender. A custom header group renderer allows column groups to be collapsed. Notice that the Total columns remain visible even when the group is collapsed.
Pivot Column Groups
1import "@1771technologies/lytenyte-pro/pill-manager.css";2import "@1771technologies/lytenyte-pro/components.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";5import { computeField, Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";6import {7 AgeGroup,8 CostCell,9 CountryCell,10 DateCell,11 GenderCell,12 HeaderGroupCell,13 NumberCell,14 ProfitCell,15 style,16} from "./components.jsx";17import { sum } from "es-toolkit";18import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";19
20export interface GridSpec {21 readonly data: SaleDataItem;22 readonly column: {23 pivotable?: boolean;24 };25}26
27export const columns: Grid.Column<GridSpec>[] = [28 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },29 { id: "age", name: "Age", type: "number", width: 80 },30 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110, pivotable: true },31 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80, pivotable: true },32 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150, pivotable: true },33 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },34 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },35 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },36 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },37 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },38 { id: "state", name: "State", width: 150, pivotable: true },39 { id: "product", name: "Product", width: 160, pivotable: true },40 { id: "productCategory", name: "Category", width: 120, pivotable: true },41 { id: "subCategory", name: "Sub-Category", width: 160, pivotable: true },42];43
44const base: Grid.ColumnBase<GridSpec> = { width: 120 };45
46const group: Grid.RowGroupColumn<GridSpec> = {47 cellRenderer: RowGroupCell,48 width: 200,49 pin: "start",50};51
52const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {53 const values = data.map((x) => computeField<number>(field, x));54 return sum(values);55};56
57export default function PivotDemo() {58 const ds = useClientDataSource<GridSpec>({59 data: salesData,60 pivotMode: true,61 pivotModel: {62 columns: [{ id: "ageGroup" }, { id: "customerGender" }],63 measures: [64 {65 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 130 },66 fn: "sum",67 },68 ],69 },70 aggregateFns: { sum: aggSum },71 });72
73 const pivotProps = ds.usePivotProps();74
75 return (76 <div className="ln-grid" style={{ height: 500 }}>77 <Grid78 columns={columns}79 rowSource={ds}80 columnBase={base}81 rowGroupColumn={group}82 columnGroupRenderer={HeaderGroupCell}83 styles={style}84 {...pivotProps}85 />86 </div>87 );88}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons";8
9// Need to set header group overflow to unset to allow sticky header labels.10export const style: Grid.Style = { headerGroup: { style: { overflow: "unset" } } };11
12export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {13 const field = api.columnField(column, row);14
15 if (typeof field !== "string") return "-";16
17 const dateField = parse(field as string, "MM/dd/yyyy", new Date());18
19 if (!isValid(dateField)) return "-";20
21 const niceDate = format(dateField, "yyyy MMM dd");22 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;23}24
25export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {26 const field = column.name ?? column.id;27
28 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;29 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;30 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;31
32 if (field === "Grand Total") return "Grand Total";33
34 return "Other";35}36
37export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {38 const field = api.columnField(column, row);39
40 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;41 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;42 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;43
44 return "-";45}46
47export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {48 const field = api.columnField(column, row);49
50 if (field === "Male")51 return (52 <div className="flex h-full w-full items-center gap-2">53 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">54 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">55 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>56 </svg>57 </div>58 Male59 </div>60 );61
62 if (field === "Female")63 return (64 <div className="flex h-full w-full items-center gap-2">65 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">66 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">67 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>68 </svg>69 </div>70 Female71 </div>72 );73
74 return "-";75}76
77function tw(...c: ClassValue[]) {78 return twMerge(clsx(...c));79}80
81const formatter = new Intl.NumberFormat("en-US", {82 maximumFractionDigits: 2,83 minimumFractionDigits: 0,84});85export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {86 const field = api.columnField(column, row);87
88 if (typeof field !== "number") return "-";89
90 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);91
92 return (93 <div94 className={tw(95 "flex h-full w-full items-center justify-end tabular-nums",96 field < 0 && "text-red-600 dark:text-red-300",97 field > 0 && "text-green-600 dark:text-green-300",98 )}99 >100 {formatted}101 </div>102 );103}104
105export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {106 const field = api.columnField(column, row);107
108 if (typeof field !== "number") return "-";109
110 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);111
112 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;113}114
115export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {116 const field = api.columnField(column, row);117
118 if (typeof field !== "number") return "-";119
120 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);121
122 return (123 <div124 className={tw(125 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",126 )}127 >128 {formatted}129 </div>130 );131}132
133export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {134 const field = api.columnField(column, row);135
136 const flag = countryFlags[field as keyof typeof countryFlags];137 if (!flag) return "-";138
139 return (140 <div className="flex h-full w-full items-center gap-2">141 <img className="size-4" src={flag} alt={`country flag of ${field}`} />142 <span>{String(field ?? "-")}</span>143 </div>144 );145}146
147export function HeaderGroupCell({148 groupPath,149 api,150 collapsible,151 collapsed,152}: Grid.T.HeaderGroupParams<GridSpec>) {153 return (154 <>155 <div style={{ position: "sticky", insetInlineStart: "calc(var(--ln-start-offset) + 8px)" }}>156 {groupPath.at(-1)}157 </div>158 <div className="flex-1" />159 <button160 data-ln-button="secondary"161 data-ln-icon162 data-ln-size="sm"163 className="bg-ln-bg-ui-panel hover:bg-ln-bg absolute right-1"164 onClick={() => api.columnToggleGroup(groupPath)}165 >166 {collapsible && collapsed && <ChevronRightIcon />}167 {collapsible && !collapsed && <ChevronLeftIcon />}168 </button>169 </>170 );171}Pivot Column Updates
The client data source dynamically generates pivot columns and manages their column state. This lets you move and resize these generated pivot columns as shown in the demo below.
Update Pivot Columns
1import "@1771technologies/lytenyte-pro/pill-manager.css";2import "@1771technologies/lytenyte-pro/components.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";5import { computeField, Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";6import {7 AgeGroup,8 CostCell,9 CountryCell,10 DateCell,11 GenderCell,12 NumberCell,13 ProfitCell,14 StickGroupHeader,15 style,16} from "./components.jsx";17import { sum } from "es-toolkit";18import { useState } from "react";19import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";20
21export interface GridSpec {22 readonly data: SaleDataItem;23 readonly column: {24 pivotable?: boolean;25 };26}27
28export const columns: Grid.Column<GridSpec>[] = [29 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },30 { id: "age", name: "Age", type: "number", width: 80 },31 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110, pivotable: true },32 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80, pivotable: true },33 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150, pivotable: true },34 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },35 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },36 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },37 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },38 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },39 { id: "state", name: "State", width: 150, pivotable: true },40 { id: "product", name: "Product", width: 160, pivotable: true },41 { id: "productCategory", name: "Category", width: 120, pivotable: true },42 { id: "subCategory", name: "Sub-Category", width: 160, pivotable: true },43];44
45const base: Grid.ColumnBase<GridSpec> = { width: 120, resizable: true, movable: true };46
47const group: Grid.RowGroupColumn<GridSpec> = {48 cellRenderer: RowGroupCell,49 width: 200,50 pin: "start",51};52
53const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {54 const values = data.map((x) => computeField<number>(field, x));55 return sum(values);56};57
58export default function PivotDemo() {59 const ds = useClientDataSource<GridSpec>({60 data: salesData,61 pivotMode: true,62 pivotModel: {63 columns: [{ id: "ageGroup" }, { id: "customerGender" }],64 measures: [65 {66 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },67 fn: "sum",68 },69 ],70 },71 aggregateFns: { sum: aggSum },72 });73
74 const pivotProps = ds.usePivotProps();75
76 const [groupColumn, setGroupColumn] = useState(group);77
78 return (79 <div className="ln-grid" style={{ height: 500 }}>80 <Grid81 columns={columns}82 rowSource={ds}83 columnBase={base}84 rowGroupColumn={groupColumn}85 onRowGroupColumnChange={setGroupColumn}86 {...pivotProps}87 styles={style}88 columnGroupRenderer={StickGroupHeader}89 />90 </div>91 );92}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7
8// Need to set header group overflow to unset to allow sticky header labels.9export const style: Grid.Style = { headerGroup: { style: { overflow: "unset" } } };10export function StickGroupHeader(props: Grid.T.HeaderGroupParams<GridSpec>) {11 return (12 <div style={{ position: "sticky", insetInlineStart: "calc(var(--ln-start-offset) + 8px)" }}>13 {props.groupPath.at(-1)}14 </div>15 );16}17
18export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {19 const field = api.columnField(column, row);20
21 if (typeof field !== "string") return "-";22
23 const dateField = parse(field as string, "MM/dd/yyyy", new Date());24
25 if (!isValid(dateField)) return "-";26
27 const niceDate = format(dateField, "yyyy MMM dd");28 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;29}30
31export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {32 const field = column.name ?? column.id;33
34 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;35 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;36 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;37
38 if (field === "Grand Total") return "Grand Total";39
40 return "Other";41}42
43export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;47 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;48 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;49
50 return "-";51}52
53export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {54 const field = api.columnField(column, row);55
56 if (field === "Male")57 return (58 <div className="flex h-full w-full items-center gap-2">59 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">60 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">61 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>62 </svg>63 </div>64 Male65 </div>66 );67
68 if (field === "Female")69 return (70 <div className="flex h-full w-full items-center gap-2">71 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">72 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">73 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>74 </svg>75 </div>76 Female77 </div>78 );79
80 return "-";81}82
83function tw(...c: ClassValue[]) {84 return twMerge(clsx(...c));85}86
87const formatter = new Intl.NumberFormat("en-US", {88 maximumFractionDigits: 2,89 minimumFractionDigits: 0,90});91export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {92 const field = api.columnField(column, row);93
94 if (typeof field !== "number") return "-";95
96 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);97
98 return (99 <div100 className={tw(101 "flex h-full w-full items-center justify-end tabular-nums",102 field < 0 && "text-red-600 dark:text-red-300",103 field > 0 && "text-green-600 dark:text-green-300",104 )}105 >106 {formatted}107 </div>108 );109}110
111export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {112 const field = api.columnField(column, row);113
114 if (typeof field !== "number") return "-";115
116 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);117
118 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;119}120
121export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {122 const field = api.columnField(column, row);123
124 if (typeof field !== "number") return "-";125
126 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);127
128 return (129 <div130 className={tw(131 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",132 )}133 >134 {formatted}135 </div>136 );137}138
139export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {140 const field = api.columnField(column, row);141
142 const flag = countryFlags[field as keyof typeof countryFlags];143 if (!flag) return "-";144
145 return (146 <div className="flex h-full w-full items-center gap-2">147 <img className="size-4" src={flag} alt={`country flag of ${field}`} />148 <span>{String(field ?? "-")}</span>149 </div>150 );151}Pivot Column Modification
LyteNyte Grid dynamically creates pivot columns based on the provided column
configuration. Set the pivotColumnProcessor property on the
useClientDataSource hook to modify these columns before they are rendered.
Using pivotColumnProcessor, you can modify dynamically created columns in any way
that suits your application. You can reorder or remove columns, or change any column
property.
The demo below uses pivotColumnProcessor to remove the Total columns created by
the column pivots.
Modifying Pivot Columns
1import "@1771technologies/lytenyte-pro/pill-manager.css";2import "@1771technologies/lytenyte-pro/components.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";5import { computeField, Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";6import {7 AgeGroup,8 CostCell,9 CountryCell,10 DateCell,11 GenderCell,12 NumberCell,13 ProfitCell,14 StickGroupHeader,15 style,16} from "./components.jsx";17import { sum } from "es-toolkit";18import { RowGroupCell } from "@1771technologies/lytenyte-pro/components";19
20export interface GridSpec {21 readonly data: SaleDataItem;22 readonly column: {23 pivotable?: boolean;24 };25}26
27export const columns: Grid.Column<GridSpec>[] = [28 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },29 { id: "age", name: "Age", type: "number", width: 80 },30 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110, pivotable: true },31 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80, pivotable: true },32 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150, pivotable: true },33 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },34 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },35 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },36 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },37 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },38 { id: "state", name: "State", width: 150, pivotable: true },39 { id: "product", name: "Product", width: 160, pivotable: true },40 { id: "productCategory", name: "Category", width: 120, pivotable: true },41 { id: "subCategory", name: "Sub-Category", width: 160, pivotable: true },42];43
44const base: Grid.ColumnBase<GridSpec> = { width: 120, widthFlex: 1 };45
46const group: Grid.RowGroupColumn<GridSpec> = {47 cellRenderer: RowGroupCell,48 width: 200,49 pin: "start",50};51
52const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {53 const values = data.map((x) => computeField<number>(field, x));54 return sum(values);55};56
57export default function PivotDemo() {58 const ds = useClientDataSource<GridSpec>({59 data: salesData,60 pivotMode: true,61 pivotColumnProcessor: (columns) => {62 return columns.filter((x) => !x.id.includes("total"));63 },64 pivotModel: {65 columns: [{ id: "ageGroup" }, { id: "customerGender" }],66 measures: [67 {68 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },69 fn: "sum",70 },71 ],72 },73 aggregateFns: { sum: aggSum },74 });75
76 const pivotProps = ds.usePivotProps();77
78 return (79 <div className="ln-grid" style={{ height: 500 }}>80 <Grid81 columns={columns}82 rowSource={ds}83 columnBase={base}84 rowGroupColumn={group}85 {...pivotProps}86 styles={style}87 columnGroupRenderer={StickGroupHeader}88 />89 </div>90 );91}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7
8// Need to set header group overflow to unset to allow sticky header labels.9export const style: Grid.Style = { headerGroup: { style: { overflow: "unset" } } };10export function StickGroupHeader(props: Grid.T.HeaderGroupParams<GridSpec>) {11 return (12 <div style={{ position: "sticky", insetInlineStart: "calc(var(--ln-start-offset) + 8px)" }}>13 {props.groupPath.at(-1)}14 </div>15 );16}17
18export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {19 const field = api.columnField(column, row);20
21 if (typeof field !== "string") return "-";22
23 const dateField = parse(field as string, "MM/dd/yyyy", new Date());24
25 if (!isValid(dateField)) return "-";26
27 const niceDate = format(dateField, "yyyy MMM dd");28 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;29}30
31export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {32 const field = column.name ?? column.id;33
34 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;35 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;36 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;37
38 if (field === "Grand Total") return "Grand Total";39
40 return "Other";41}42
43export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;47 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;48 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;49
50 return "-";51}52
53export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {54 const field = api.columnField(column, row);55
56 if (field === "Male")57 return (58 <div className="flex h-full w-full items-center gap-2">59 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">60 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">61 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>62 </svg>63 </div>64 Male65 </div>66 );67
68 if (field === "Female")69 return (70 <div className="flex h-full w-full items-center gap-2">71 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">72 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">73 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>74 </svg>75 </div>76 Female77 </div>78 );79
80 return "-";81}82function tw(...c: ClassValue[]) {83 return twMerge(clsx(...c));84}85
86const formatter = new Intl.NumberFormat("en-US", {87 maximumFractionDigits: 2,88 minimumFractionDigits: 0,89});90export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {91 const field = api.columnField(column, row);92
93 if (typeof field !== "number") return "-";94
95 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);96
97 return (98 <div99 className={tw(100 "flex h-full w-full items-center justify-end tabular-nums",101 field < 0 && "text-red-600 dark:text-red-300",102 field > 0 && "text-green-600 dark:text-green-300",103 )}104 >105 {formatted}106 </div>107 );108}109
110export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {111 const field = api.columnField(column, row);112
113 if (typeof field !== "number") return "-";114
115 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);116
117 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;118}119
120export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {121 const field = api.columnField(column, row);122
123 if (typeof field !== "number") return "-";124
125 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);126
127 return (128 <div129 className={tw(130 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",131 )}132 >133 {formatted}134 </div>135 );136}137
138export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {139 const field = api.columnField(column, row);140
141 const flag = countryFlags[field as keyof typeof countryFlags];142 if (!flag) return "-";143
144 return (145 <div className="flex h-full w-full items-center gap-2">146 <img className="size-4" src={flag} alt={`country flag of ${field}`} />147 <span>{String(field ?? "-")}</span>148 </div>149 );150}The code for the pivot column processor is shown below. The processor returns a new array of columns based on the columns generated by the grid.
1const ds = useClientDataSource<GridSpec>({2 data: salesData,3 pivotMode: true,4 pivotColumnProcessor: (columns) => {5 return columns.filter((x) => !x.id.includes("total"));6 },7 pivotModel: {8 columns: [{ id: "ageGroup" }, { id: "customerGender" }],9 measures: [10 {11 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },12 fn: "sum",13 },14 ],15 },16 aggregateFns: { sum: aggSum },17});Pivot Rows
Add pivot row groups to extend the pivot view by another dimension. Set the rows property
on the pivotModel to group rows before measurement. Note that, unlike standard
row grouping, the final level of a pivot row group represents an
aggregated result and cannot be expanded.
The demo below shows pivot rows. Each pivot row adds a grouping level. Since pivoting rotates data dimensions, row and column definitions are interchangeable. Click the Swap button to invert the current configuration.
Row Pivots
1import "@1771technologies/lytenyte-pro/pill-manager.css";2import "@1771technologies/lytenyte-pro/components.css";3import "@1771technologies/lytenyte-pro/light-dark.css";4import { salesData, type SaleDataItem } from "@1771technologies/grid-sample-data/sales-data";5import { computeField, Grid, useClientDataSource } from "@1771technologies/lytenyte-pro";6import {7 AgeGroup,8 CostCell,9 CountryCell,10 DateCell,11 GenderCell,12 NumberCell,13 ProfitCell,14 StickGroupHeader,15 style,16} from "./components.jsx";17import { sum } from "es-toolkit";18import { useMemo, useState } from "react";19import { PillManager, RowGroupCell } from "@1771technologies/lytenyte-pro/components";20
21export interface GridSpec {22 readonly data: SaleDataItem;23 readonly column: {24 pivotable?: boolean;25 };26}27
28export const columns: Grid.Column<GridSpec>[] = [29 { id: "date", name: "Date", cellRenderer: DateCell, width: 110 },30 { id: "age", name: "Age", type: "number", width: 80 },31 { id: "ageGroup", name: "Age Group", cellRenderer: AgeGroup, width: 110, pivotable: true },32 { id: "customerGender", name: "Gender", cellRenderer: GenderCell, width: 80, pivotable: true },33 { id: "country", name: "Country", cellRenderer: CountryCell, width: 150, pivotable: true },34 { id: "orderQuantity", name: "Quantity", type: "number", width: 60 },35 { id: "unitPrice", name: "Price", type: "number", width: 80, cellRenderer: NumberCell },36 { id: "cost", name: "Cost", width: 80, type: "number", cellRenderer: CostCell },37 { id: "revenue", name: "Revenue", width: 80, type: "number", cellRenderer: ProfitCell },38 { id: "profit", name: "Profit", width: 80, type: "number", cellRenderer: ProfitCell },39 { id: "state", name: "State", width: 150, pivotable: true },40 { id: "product", name: "Product", width: 160, pivotable: true },41 { id: "productCategory", name: "Category", width: 120, pivotable: true },42 { id: "subCategory", name: "Sub-Category", width: 160, pivotable: true },43];44
45const base: Grid.ColumnBase<GridSpec> = { width: 120, resizable: true };46
47const group: Grid.RowGroupColumn<GridSpec> = {48 cellRenderer: RowGroupCell,49 width: 200,50 pin: "start",51};52
53const aggSum: Grid.T.Aggregator<GridSpec["data"]> = (field, data) => {54 const values = data.map((x) => computeField<number>(field, x));55 return sum(values);56};57
58export default function PivotDemo() {59 const [colPivots, setColPivots] = useState<PillManager.T.PillItem[]>(() =>60 columns61 .filter((x) => x.pivotable)62 .map((x) => ({ id: x.id, name: x.name ?? x.id, active: x.id === "ageGroup" })),63 );64 const [rowPivots, setRowPivots] = useState<PillManager.T.PillItem[]>(() => {65 const pivotables = columns66 .filter((x) => x.pivotable)67 .map((x) => ({68 id: x.id,69 name: x.name ?? x.id,70 active: x.id === "country",71 }));72
73 const active = [...pivotables.filter((x) => x.active)];74 const notActive = [...pivotables.filter((x) => !x.active)];75
76 return [...active, ...notActive];77 });78
79 const ds = useClientDataSource<GridSpec>({80 data: salesData,81 pivotMode: true,82 pivotModel: {83 columns: colPivots.filter((x) => x.active),84 rows: rowPivots.filter((x) => x.active),85 measures: [86 {87 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },88 fn: "sum",89 },90 ],91 },92 aggregateFns: { sum: aggSum },93 });94
95 const pillRows = useMemo<PillManager.T.PillRow[]>(() => {96 const colPivotPills = colPivots.map((x) => {97 return {98 id: x.id,99 active: x.active,100 movable: x.active,101 name: x.name ?? x.id,102 data: x,103 removable: true,104 };105 });106 const rowPivotPills = rowPivots.map((x) => {107 return {108 id: x.id,109 active: x.active,110 movable: x.active,111 name: x.name ?? x.id,112 data: x,113 removable: true,114 };115 });116 return [117 {118 id: "column-pivots",119 label: "Column Pivots",120 type: "column-pivots",121 pills: colPivotPills,122 accepts: ["row-pivots"],123 },124 {125 id: "row-pivots",126 label: "Row Pivots",127 type: "row-pivots",128 pills: rowPivotPills,129 accepts: ["column-pivots"],130 },131 ];132 }, [colPivots, rowPivots]);133
134 const pivotProps = ds.usePivotProps();135 const [groupColumn, setGroupColumn] = useState(group);136 return (137 <>138 <div className="@container">139 <PillManager140 rows={pillRows}141 onPillItemActiveChange={(p) => {142 setColPivots((prev) => {143 const next = prev.map((x) =>144 x.id === p.item.id ? { ...x, active: p.item.active && p.row.id === "column-pivots" } : x,145 );146 return [...next.filter((x) => x.active), ...next.filter((x) => !x.active)];147 });148 setRowPivots((prev) => {149 const next = prev.map((x) =>150 x.id === p.item.id ? { ...x, active: p.item.active && p.row.id === "row-pivots" } : x,151 );152 return [...next.filter((x) => x.active), ...next.filter((x) => !x.active)];153 });154 }}155 onPillRowChange={(ev) => {156 for (const changed of ev.changed) {157 const activeFirst = changed.pills.filter((x) => x.active);158 const nonActive = changed.pills.filter((x) => !x.active);159 if (changed.id === "column-pivots") {160 setColPivots([...activeFirst, ...nonActive]);161 }162 if (changed.id === "row-pivots") {163 setRowPivots([...activeFirst, ...nonActive]);164 }165 }166 }}167 >168 {(row) => {169 return (170 <PillManager.Row row={row} className="relative">171 {row.id === "column-pivots" && (172 <SwapPivots173 onSwap={() => {174 setColPivots(rowPivots);175 setRowPivots(colPivots);176 }}177 />178 )}179 <PillManager.Label row={row} />180 <PillManager.Container>181 {row.pills.map((x) => {182 return <PillManager.Pill item={x} key={x.id} />;183 })}184 </PillManager.Container>185 <PillManager.Expander />186 </PillManager.Row>187 );188 }}189 </PillManager>190 </div>191 <div className="ln-grid" style={{ height: 500 }}>192 <Grid193 columns={columns}194 rowSource={ds}195 columnBase={base}196 rowGroupColumn={groupColumn}197 onRowGroupColumnChange={setGroupColumn}198 {...pivotProps}199 columnGroupRenderer={StickGroupHeader}200 styles={style}201 />202 </div>203 </>204 );205}206
207function SwapPivots({ onSwap }: { onSwap: () => void }) {208 return (209 <button210 className="bg-ln-gray-02 border-ln-border-strong text-ln-primary-50 hover:bg-ln-gray-10 z-1 @2xl:-bottom-2.5 @2xl:left-7 absolute -bottom-2 left-3 flex cursor-pointer items-center gap-1 rounded-2xl border px-1 py-0.5 text-[10px]"211 onClick={() => onSwap()}212 >213 <span>214 <svg width="12" height="11" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">215 <path216 d="M13.4819 6.09082L12.0422 7.53048L10.6025 6.09082"217 stroke="currentcolor"218 strokeWidth="1.5"219 strokeLinecap="round"220 strokeLinejoin="round"221 />222 <path223 d="M0.749587 7.42017L2.18925 5.98051L3.62891 7.42017"224 stroke="currentcolor"225 strokeWidth="1.5"226 strokeLinecap="round"227 strokeLinejoin="round"228 />229 <path230 d="M12.0962 7.34067C12.1038 7.22805 12.1077 7.11441 12.1077 6.99985C12.1077 4.261 9.88744 2.04072 7.14859 2.04072C5.7388 2.04072 4.46641 2.62899 3.5635 3.57345M2.23753 6.30658C2.20584 6.53312 2.18945 6.76457 2.18945 6.99985C2.18945 9.73871 4.40973 11.959 7.14859 11.959C8.68732 11.959 10.0624 11.2582 10.972 10.1583"231 stroke="currentcolor"232 strokeWidth="1.5"233 strokeLinecap="round"234 strokeLinejoin="round"235 />236 </svg>237 </span>238 <span className="sr-only">Swap row and column pivots</span>239 <span className="@2xl:block hidden">SWAP</span>240 </button>241 );242}1import type { Grid } from "@1771technologies/lytenyte-pro";2import type { GridSpec } from "./demo";3import { format, isValid, parse } from "date-fns";4import { countryFlags } from "@1771technologies/grid-sample-data/sales-data";5import { clsx, type ClassValue } from "clsx";6import { twMerge } from "tailwind-merge";7
8// Need to set header group overflow to unset to allow sticky header labels.9export const style: Grid.Style = { headerGroup: { style: { overflow: "unset" } } };10export function StickGroupHeader(props: Grid.T.HeaderGroupParams<GridSpec>) {11 return (12 <div style={{ position: "sticky", insetInlineStart: "calc(var(--ln-start-offset) + 8px)" }}>13 {props.groupPath.at(-1)}14 </div>15 );16}17
18export function DateCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {19 const field = api.columnField(column, row);20
21 if (typeof field !== "string") return "-";22
23 const dateField = parse(field as string, "MM/dd/yyyy", new Date());24
25 if (!isValid(dateField)) return "-";26
27 const niceDate = format(dateField, "yyyy MMM dd");28 return <div className="flex h-full w-full items-center tabular-nums">{niceDate}</div>;29}30
31export function AgeGroupPivotHeader({ column }: Grid.T.HeaderParams<GridSpec>) {32 const field = column.name ?? column.id;33
34 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;35 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;36 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;37
38 if (field === "Grand Total") return "Grand Total";39
40 return "Other";41}42
43export function AgeGroup({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {44 const field = api.columnField(column, row);45
46 if (field === "Under 25") return <div className="text-[#944cec] dark:text-[#B181EB]">Under 25</div>;47 if (field === "25-34") return <div className="text-[#aa6c1a] dark:text-[#E5B474]">25-34</div>;48 if (field === "35-64") return <div className="text-[#0f7d4c] dark:text-[#52B086]">35-64</div>;49
50 return "-";51}52
53export function GenderCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {54 const field = api.columnField(column, row);55
56 if (field === "Male")57 return (58 <div className="flex h-full w-full items-center gap-2">59 <div className="flex size-6 items-center justify-center rounded-full bg-blue-500/50">60 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">61 <path d="M216,28H168a12,12,0,0,0,0,24h19L154.28,84.74a84,84,0,1,0,17,17L204,69V88a12,12,0,0,0,24,0V40A12,12,0,0,0,216,28ZM146.41,194.46a60,60,0,1,1,0-84.87A60.1,60.1,0,0,1,146.41,194.46Z"></path>62 </svg>63 </div>64 Male65 </div>66 );67
68 if (field === "Female")69 return (70 <div className="flex h-full w-full items-center gap-2">71 <div className="flex size-6 items-center justify-center rounded-full bg-pink-500/50">72 <svg width="16" height="16" fill="currentcolor" viewBox="0 0 256 256">73 <path d="M212,96a84,84,0,1,0-96,83.13V196H88a12,12,0,0,0,0,24h28v20a12,12,0,0,0,24,0V220h28a12,12,0,0,0,0-24H140V179.13A84.12,84.12,0,0,0,212,96ZM68,96a60,60,0,1,1,60,60A60.07,60.07,0,0,1,68,96Z"></path>74 </svg>75 </div>76 Female77 </div>78 );79
80 return "-";81}82
83function tw(...c: ClassValue[]) {84 return twMerge(clsx(...c));85}86
87const formatter = new Intl.NumberFormat("en-US", {88 maximumFractionDigits: 2,89 minimumFractionDigits: 0,90});91export function ProfitCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {92 const field = api.columnField(column, row);93
94 if (typeof field !== "number") return "-";95
96 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);97
98 return (99 <div100 className={tw(101 "flex h-full w-full items-center justify-end tabular-nums",102 field < 0 && "text-red-600 dark:text-red-300",103 field > 0 && "text-green-600 dark:text-green-300",104 )}105 >106 {formatted}107 </div>108 );109}110
111export function NumberCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {112 const field = api.columnField(column, row);113
114 if (typeof field !== "number") return "-";115
116 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);117
118 return <div className={"flex h-full w-full items-center justify-end tabular-nums"}>{formatted}</div>;119}120
121export function CostCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {122 const field = api.columnField(column, row);123
124 if (typeof field !== "number") return "-";125
126 const formatted = field < 0 ? `-$${formatter.format(Math.abs(field))}` : "$" + formatter.format(field);127
128 return (129 <div130 className={tw(131 "flex h-full w-full items-center justify-end tabular-nums text-red-600 dark:text-red-300",132 )}133 >134 {formatted}135 </div>136 );137}138
139export function CountryCell({ api, row, column }: Grid.T.CellRendererParams<GridSpec>) {140 const field = api.columnField(column, row);141
142 const flag = countryFlags[field as keyof typeof countryFlags];143 if (!flag) return "-";144
145 return (146 <div className="flex h-full w-full items-center gap-2">147 <img className="size-4" src={flag} alt={`country flag of ${field}`} />148 <span>{String(field ?? "-")}</span>149 </div>150 );151}The demo uses the Pill Manager component to configure pivots. React state stores the pivot configuration and updates it when pills are clicked or dragged. The updated pivot state is then mapped to the client data source, as shown in the code below.
1const [colPivots, setColPivots] = useState<PillManager.T.PillItem[]>(initialPivots);2const [rowPivots, setRowPivots] = useState<PillManager.T.PillItem[]>(initialColPivots);3
4const ds = useClientDataSource<GridSpec>({5 data: salesData,6 pivotMode: true,7 pivotModel: {8 columns: colPivots.filter((x) => x.active),9 rows: rowPivots.filter((x) => x.active),10 measures: [11 {12 dim: { id: "profit", name: "Profit", type: "number", cellRenderer: ProfitCell, width: 120 },13 fn: "sum",14 },15 ],16 },17 aggregateFns: { sum: aggSum },18});Next Steps
- Measures: Aggregate and summarize row data when pivoting.
- Pivot Filters: Filter pivots by labels or predicate conditions.
- Grand Totals: Display a grand total across all pivot measures.
