From 04464ababb74fe88effc65c4bd7bfd2813714cb4 Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 15:44:39 +0530 Subject: [PATCH 01/18] feat(locale): translate the AI assistant's change chips 11 localeText keys for the chips (search, sort, group, summary, pivot, visibility and their empty states); operators use filterPanelOperators --- CHANGELOG.md | 8 ++++ lib/hooks/features/useGridAiAssistant.ts | 5 +- lib/types/locale.ts | 22 +++++++++ lib/utils/aiAssistant.ts | 61 ++++++++++++++++++------ lib/utils/gridLocale.test.ts | 16 +++++++ lib/utils/gridLocale.ts | 30 ++++++++++++ 6 files changed, 125 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe74c95a..ef7cc6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ --- +## [Unreleased — 3.10.0] + +### Added + +- **The AI assistant's change chips are translatable**: 11 `localeText` keys (`aiAssistantChipSearch`, `ChipNoFilters`, `ChipSort(field, direction)`, `ChipNoSorting`, `ChipGroup`, `ChipNoGrouping`, `ChipSummary(fn, field)`, `ChipNoSummaries`, `ChipPivot`, `ChipNoPivot`, `ChipVisibility(field, visible)`), English by default; filter operators in the chips use `filterPanelOperators`. `buildAiChips` takes the texts as an optional third argument. + +--- + ## [3.9.1] — 2026-10-13 ### Fixed diff --git a/lib/hooks/features/useGridAiAssistant.ts b/lib/hooks/features/useGridAiAssistant.ts index 64e6b2f6..e3706c10 100644 --- a/lib/hooks/features/useGridAiAssistant.ts +++ b/lib/hooks/features/useGridAiAssistant.ts @@ -12,7 +12,7 @@ import type { GridColDef, GridLocaleText, } from '../../types'; -import { resolveGridLocale, type ResolvedGridLocale } from '../../utils/gridLocale'; +import { aiChipTextsOf, resolveGridLocale, type ResolvedGridLocale } from '../../utils/gridLocale'; import { attempt } from '../../utils/attempt'; import { isGridAiValidated } from '../../utils/aiBrand'; import { @@ -143,7 +143,8 @@ export function useGridAiAssistant(params: UseGridAiAssistantParams): UseGridAiA const byField = new Map(columns.map((col) => [col.field, col.headerName || col.field])); return (field: string) => byField.get(field) ?? field; }, [columns]); - const chips = useMemo(() => (applied ? buildAiChips(applied.state, labelOf) : []), [applied, labelOf]); + const chipTexts = useMemo(() => aiChipTextsOf(locale), [locale]); + const chips = useMemo(() => (applied ? buildAiChips(applied.state, labelOf, chipTexts) : []), [applied, labelOf, chipTexts]); const abortRequest = useCallback(() => { const controller = controllerRef.current; diff --git a/lib/types/locale.ts b/lib/types/locale.ts index 8cca5768..fe4b5b10 100644 --- a/lib/types/locale.ts +++ b/lib/types/locale.ts @@ -364,6 +364,28 @@ export interface GridLocaleText { aiAssistantFailed?: (message: string) => string; /** Error text when a failed request has no message. Default: `"Unknown error"` @since v3.9 */ aiAssistantUnknownError?: string; + /** Change chip for the quick-filter words. Default: `` (w) => `Search: ${w}` `` @since v3.10 */ + aiAssistantChipSearch?: (words: string) => string; + /** Change chip when the reply cleared the filters. Default: `"No filters"` @since v3.10 */ + aiAssistantChipNoFilters?: string; + /** Change chip for one sort key. Default: `` (f, d) => `Sort: ${f}, ascending|descending` `` @since v3.10 */ + aiAssistantChipSort?: (field: string, direction: 'asc' | 'desc') => string; + /** Change chip when the reply cleared the sort. Default: `"No sorting"` @since v3.10 */ + aiAssistantChipNoSorting?: string; + /** Change chip for one grouping field. Default: `` (f) => `Group: ${f}` `` @since v3.10 */ + aiAssistantChipGroup?: (field: string) => string; + /** Change chip when the reply removed grouping. Default: `"No grouping"` @since v3.10 */ + aiAssistantChipNoGrouping?: string; + /** Change chip for one summary; `fn` is the function id (`sum`, `avg`, …). Default: `` (fn, f) => `Sum: ${f}` `` (Sum, Average, Count, Min, Max, Unique) @since v3.10 */ + aiAssistantChipSummary?: (fn: string, field: string) => string; + /** Change chip when the reply removed the summaries. Default: `"No summaries"` @since v3.10 */ + aiAssistantChipNoSummaries?: string; + /** Change chip for the pivot; receives the column labels joined with ", ". Default: `` (f) => `Pivot: ${f}` `` @since v3.10 */ + aiAssistantChipPivot?: (fields: string) => string; + /** Change chip when the reply turned the pivot off. Default: `"No pivot"` @since v3.10 */ + aiAssistantChipNoPivot?: string; + /** Change chip for a column shown or hidden. Default: `` (f, v) => `Show: ${f}` `` / `` `Hide: ${f}` `` @since v3.10 */ + aiAssistantChipVisibility?: (field: string, visible: boolean) => string; // ── Pagination (v3.9) ───────────────────────────────────────────────────── /** `aria-label` of the pagination bar. Default: `"Pagination"` @since v3.9 */ diff --git a/lib/utils/aiAssistant.ts b/lib/utils/aiAssistant.ts index efc85f6c..f2e77171 100644 --- a/lib/utils/aiAssistant.ts +++ b/lib/utils/aiAssistant.ts @@ -119,47 +119,78 @@ function valueText(value: unknown): string { return value === undefined || value === null ? '' : String(value); } -function filterLabel(node: GridFilterItem | GridFilterGroup, label: (field: string) => string): string { - if ('items' in node) return `(${node.items.map((child) => filterLabel(child, label)).join(` ${node.logicOperator} `)})`; - return [label(node.field), node.operator, valueText(node.value)].filter(Boolean).join(' '); +function filterLabel(node: GridFilterItem | GridFilterGroup, label: (field: string) => string, operator: (op: string) => string): string { + if ('items' in node) return `(${node.items.map((child) => filterLabel(child, label, operator)).join(` ${node.logicOperator} `)})`; + return [label(node.field), operator(node.operator), valueText(node.value)].filter(Boolean).join(' '); } const FN_LABELS: Record = { sum: 'Sum', avg: 'Average', count: 'Count', min: 'Min', max: 'Max', unique: 'Unique' }; +/** The chips' texts (the grid passes its `localeText`; English by default). */ +export interface GridAiChipTexts { + operator: (operator: string) => string; + search: (words: string) => string; + noFilters: string; + sort: (field: string, direction: 'asc' | 'desc') => string; + noSorting: string; + group: (field: string) => string; + noGrouping: string; + summary: (fn: string, field: string) => string; + noSummaries: string; + pivot: (fields: string) => string; + noPivot: string; + visibility: (field: string, visible: boolean) => string; +} + +export const AI_CHIP_TEXTS_EN: GridAiChipTexts = { + operator: op => op, + search: words => `Search: ${words}`, + noFilters: 'No filters', + sort: (field, direction) => `Sort: ${field}, ${direction === 'desc' ? 'descending' : 'ascending'}`, + noSorting: 'No sorting', + group: field => `Group: ${field}`, + noGrouping: 'No grouping', + summary: (fn, field) => `${FN_LABELS[fn] ?? fn}: ${field}`, + noSummaries: 'No summaries', + pivot: fields => `Pivot: ${fields}`, + noPivot: 'No pivot', + visibility: (field, visible) => `${visible ? 'Show' : 'Hide'}: ${field}`, +}; + /** One chip per change: filter item, quick-filter words, sort key, grouping field, aggregation, visibility change, pivot. */ -export function buildAiChips(applied: GridAiState, label: (field: string) => string): GridAiChip[] { +export function buildAiChips(applied: GridAiState, label: (field: string) => string, texts: GridAiChipTexts = AI_CHIP_TEXTS_EN): GridAiChip[] { const chips: GridAiChip[] = []; const { filterModel, sortModel, rowGroupingModel, aggregationModel, pivotModel, columnVisibilityModel } = applied; if (filterModel) { const items = filterModel.items ?? []; - items.forEach((item, i) => chips.push({ id: `filter:${i}`, part: 'filter', label: filterLabel(item, label) })); + items.forEach((item, i) => chips.push({ id: `filter:${i}`, part: 'filter', label: filterLabel(item, label, texts.operator) })); const words = filterModel.quickFilterValues ?? []; - if (words.length > 0) chips.push({ id: 'filter:quick', part: 'filter', label: `Search: ${words.join(' ')}` }); - if (items.length === 0 && words.length === 0) chips.push({ id: 'filter:clear', part: 'filter', label: 'No filters' }); + if (words.length > 0) chips.push({ id: 'filter:quick', part: 'filter', label: texts.search(words.join(' ')) }); + if (items.length === 0 && words.length === 0) chips.push({ id: 'filter:clear', part: 'filter', label: texts.noFilters }); } if (sortModel) { - sortModel.forEach((s, i) => chips.push({ id: `sort:${i}`, part: 'sort', label: `Sort: ${label(s.field)}, ${s.sort === 'desc' ? 'descending' : 'ascending'}` })); - if (sortModel.length === 0) chips.push({ id: 'sort:clear', part: 'sort', label: 'No sorting' }); + sortModel.forEach((s, i) => chips.push({ id: `sort:${i}`, part: 'sort', label: texts.sort(label(s.field), s.sort === 'desc' ? 'desc' : 'asc') })); + if (sortModel.length === 0) chips.push({ id: 'sort:clear', part: 'sort', label: texts.noSorting }); } if (rowGroupingModel) { - rowGroupingModel.forEach((field, i) => chips.push({ id: `grouping:${i}`, part: 'grouping', label: `Group: ${label(field)}` })); - if (rowGroupingModel.length === 0) chips.push({ id: 'grouping:clear', part: 'grouping', label: 'No grouping' }); + rowGroupingModel.forEach((field, i) => chips.push({ id: `grouping:${i}`, part: 'grouping', label: texts.group(label(field)) })); + if (rowGroupingModel.length === 0) chips.push({ id: 'grouping:clear', part: 'grouping', label: texts.noGrouping }); } if (aggregationModel) { const fields = Object.keys(aggregationModel); fields.forEach((field) => { const fn = aggregationModel[field]; - chips.push({ id: `aggregation:${field}`, part: 'aggregation', label: `${FN_LABELS[fn] ?? fn}: ${label(field)}` }); + chips.push({ id: `aggregation:${field}`, part: 'aggregation', label: texts.summary(fn, label(field)) }); }); - if (fields.length === 0) chips.push({ id: 'aggregation:clear', part: 'aggregation', label: 'No summaries' }); + if (fields.length === 0) chips.push({ id: 'aggregation:clear', part: 'aggregation', label: texts.noSummaries }); } if (pivotModel) { const fields = [...pivotModel.rowFields, ...pivotModel.columnFields, ...pivotModel.valueFields.map((v) => v.field)]; - chips.push({ id: 'pivot', part: 'pivot', label: fields.length > 0 ? `Pivot: ${fields.map(label).join(', ')}` : 'No pivot' }); + chips.push({ id: 'pivot', part: 'pivot', label: fields.length > 0 ? texts.pivot(fields.map(label).join(', ')) : texts.noPivot }); } if (columnVisibilityModel) { for (const field of Object.keys(columnVisibilityModel)) { - chips.push({ id: `columnVisibility:${field}`, part: 'columnVisibility', label: `${columnVisibilityModel[field] ? 'Show' : 'Hide'}: ${label(field)}` }); + chips.push({ id: `columnVisibility:${field}`, part: 'columnVisibility', label: texts.visibility(label(field), Boolean(columnVisibilityModel[field])) }); } } return chips; diff --git a/lib/utils/gridLocale.test.ts b/lib/utils/gridLocale.test.ts index 5e40649a..d933d5c7 100644 --- a/lib/utils/gridLocale.test.ts +++ b/lib/utils/gridLocale.test.ts @@ -79,3 +79,19 @@ describe('resolveGridLocale', () => { expect(GRID_LOCALE_EN.gridLabel).toBe('Data grid'); }); }); + +describe('AI change chip texts (v3.10)', () => { + it('defaults to the English chips and takes overrides', async () => { + const { buildAiChips } = await import('./aiAssistant'); + const { aiChipTextsOf } = await import('./gridLocale'); + const state = { sortModel: [{ field: 'amount', sort: 'desc' as const }], rowGroupingModel: [], aggregationModel: { amount: 'avg' } }; + const en = buildAiChips(state, f => f, aiChipTextsOf(resolveGridLocale(undefined))).map(c => c.label); + expect(en).toEqual(['Sort: amount, descending', 'No grouping', 'Average: amount']); + const fr = buildAiChips(state, f => f, aiChipTextsOf(resolveGridLocale({ + aiAssistantChipSort: (f, d) => `Tri : ${f}, ${d === 'desc' ? 'décroissant' : 'croissant'}`, + aiAssistantChipNoGrouping: 'Aucun regroupement', + aiAssistantChipSummary: (fn, f) => `${fn === 'avg' ? 'Moyenne' : fn} : ${f}`, + }))).map(c => c.label); + expect(fr).toEqual(['Tri : amount, décroissant', 'Aucun regroupement', 'Moyenne : amount']); + }); +}); diff --git a/lib/utils/gridLocale.ts b/lib/utils/gridLocale.ts index 464d4c24..5e2e55d0 100644 --- a/lib/utils/gridLocale.ts +++ b/lib/utils/gridLocale.ts @@ -1,6 +1,7 @@ import type { GridFilterOperator, GridLocaleText } from '../types'; import { HEADER_FILTER_OPERATOR_LABELS } from './headerFilters'; import { defaultCellSelectionAnnouncement } from './cellSelection'; +import { AI_CHIP_TEXTS_EN, type GridAiChipTexts } from './aiAssistant'; /** `localeText` keys that hold a lookup record; the resolved locale has a function for each. */ type GridLocaleRecordKey = 'headerFilterOperators' | 'filterPanelOperators' | 'toolbarAggregationFunctions' | 'pivotAggregationFunctions'; @@ -197,6 +198,17 @@ export const GRID_LOCALE_EN: GridLocaleScalars = { aiAssistantNotValidated: 'The reply was not validated', aiAssistantFailed: m => `The assistant could not answer: ${m}`, aiAssistantUnknownError: 'Unknown error', + aiAssistantChipSearch: AI_CHIP_TEXTS_EN.search, + aiAssistantChipNoFilters: AI_CHIP_TEXTS_EN.noFilters, + aiAssistantChipSort: AI_CHIP_TEXTS_EN.sort, + aiAssistantChipNoSorting: AI_CHIP_TEXTS_EN.noSorting, + aiAssistantChipGroup: AI_CHIP_TEXTS_EN.group, + aiAssistantChipNoGrouping: AI_CHIP_TEXTS_EN.noGrouping, + aiAssistantChipSummary: AI_CHIP_TEXTS_EN.summary, + aiAssistantChipNoSummaries: AI_CHIP_TEXTS_EN.noSummaries, + aiAssistantChipPivot: AI_CHIP_TEXTS_EN.pivot, + aiAssistantChipNoPivot: AI_CHIP_TEXTS_EN.noPivot, + aiAssistantChipVisibility: AI_CHIP_TEXTS_EN.visibility, paginationLabel: 'Pagination', paginationFirstPageLabel: 'Go to first page', @@ -220,6 +232,24 @@ function pickScalar(target: GridLocaleScalars, lo if (value !== undefined) target[key] = value as GridLocaleScalars[K]; } +/** The AI assistant's change-chip texts out of a resolved locale. */ +export function aiChipTextsOf(locale: ResolvedGridLocale): GridAiChipTexts { + return { + operator: op => locale.filterPanelOperator(op as GridFilterOperator), + search: locale.aiAssistantChipSearch, + noFilters: locale.aiAssistantChipNoFilters, + sort: locale.aiAssistantChipSort, + noSorting: locale.aiAssistantChipNoSorting, + group: locale.aiAssistantChipGroup, + noGrouping: locale.aiAssistantChipNoGrouping, + summary: locale.aiAssistantChipSummary, + noSummaries: locale.aiAssistantChipNoSummaries, + pivot: locale.aiAssistantChipPivot, + noPivot: locale.aiAssistantChipNoPivot, + visibility: locale.aiAssistantChipVisibility, + }; +} + /** * Fills every string `localeText` leaves out with its English default. Pure; memoise the result * per `localeText` object. From 9fc6599859c66d2dd858309afc2b06ffc43b3d7d Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 15:51:49 +0530 Subject: [PATCH 02/18] docs: feature-modules proposal for the opt-in bundle size; record P9 decision --- docs/superpowers/plans/2026-10-13-audit-tracker.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md index c482a22b..5acf3585 100644 --- a/docs/superpowers/plans/2026-10-13-audit-tracker.md +++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md @@ -33,7 +33,7 @@ Release plan: | P5 | Cheaper select-all and incremental group expand | 1M select-all 316 → <50 ms | M | 3.8.0 | ✅ | Group toggle: leaf metas built once + `LayeredMap` (expand 279 → ~135 ms, collapse ~120 → ~1 ms). Select-all ~270 ms unchanged: bounded by the public array `rowSelectionModel` (several 1M-id Set builds); only the live-selection copy was removed | | P7 | Per-row quick-filter cache (WeakMap) | no rebuild per edit; less memory (~32 MB/100k now) | M | 3.8.0 | ❌ | Not needed: `filterRows` already reuses texts per row object at the same index, so an edit re-reads only the edited row | | P8 | Skip re-render of unchanged cells/header; remove double commit | ~half React work per interaction | M | later | ✅ | Rows re-rendered on every click/edit/scroll: `onRowClick` + selection handlers stable (latest ref), `editingCell` only to the edited row, `virtualColumns` own memo. Click: 14 rows → 1. Double commit = two separate updates; left as is | -| P9 | Lazy/modular opt-in features | up to ~35 KB gz for minimal apps | L | later | ⬜ | | +| P9 | Lazy/modular opt-in features | up to ~35 KB gz for minimal apps | L | later | ⏸ | Measured: DataGrid-only app 87 KB gz. Lazy UI would save ~6 KB gz (rejected: flicker, chunks, no UMD gain). Feature modules (~20 KB gz) need a breaking API → `docs/superpowers/specs/2026-10-13-feature-modules-proposal.md`, for 4.0 | Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fill 783 ms · select-all 316 ms · expand 279 ms · sort 403 ms. @@ -71,3 +71,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil - 2026-10-13 — R7 done (agent, reviewed): 196 localeText keys via GridLocaleContext; added useShallowStableValue so an inline localeText of strings keeps rows memoised. Unit 1972, browser 585/585 in 3 engines. - 2026-10-13 — Released 3.9.0 (CI run 38381619839). - 2026-10-13 — 3.9.1 patch (PR flow): baseline exclusion, check:package, version badges, docs. Cherry-picked from release/3.10.0. +- 2026-10-13 — AI change chips translatable (11 keys, branch `release/3.10.0`). P9 measured and deferred to a 4.0 feature-modules proposal (user agreed). From defece96e05f75919dbf0163c432e760d52359d5 Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 15:58:24 +0530 Subject: [PATCH 03/18] docs: close audit item B7 (duplicate ids already warn) --- docs/superpowers/plans/2026-10-13-audit-tracker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md index 5acf3585..9a32c14a 100644 --- a/docs/superpowers/plans/2026-10-13-audit-tracker.md +++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md @@ -17,7 +17,7 @@ Release plan: | B4 | Med | Junk paste ("hello 5") into date column saved as a 2001 date | `lib/utils/values.ts:127` | 3.7.1 | ✅ | `hasOnlyDateWords` in `toDate` (loop, no regex) | | B5 | Med | Date paste into ISO date-time string lands on previous day west of UTC | `lib/utils/parsing.ts:150-151` | 3.7.1 | ✅ | zoned current → local time of day on pasted day, written as UTC; tested in 3 TZs | | B6 | Med | List view reads undeclared `--ogx-grid-cell-text`; selection colours bypass theme | `ListView.css:82` | 3.7.1 | ✅ | list view uses `--ogx-grid-row-*` vars; old `--ogx-selection-*` still win | -| B7 | Low–Med | Duplicate row ids silently drop rows (no dev warning) | row store | later | ⬜ | | +| B7 | Low–Med | Duplicate row ids silently drop rows (no dev warning) | row store | later | ✅ | Already handled since 3.0: dev warning naming the ids (`buildRowsState`, tested in `DataGrid.state.test.tsx`); the audit overstated it. Warns once per page | | B8 | Low | Quick filter stale on in-place row mutation | quick-filter cache | later | ⏸ | Inherent to the per-row text cache; documented in `docs/features/filtering.md` | | B9 | Low | Quick filter stale when a formatter closure changes | quick-filter cache | later | ⏸ | Inherent to the cache; documented (pass new column definitions) | | B10 | Low | `bigint` loses precision in sort | `lib/utils/sorting/index.ts:45` | later | ✅ | Exact re-ranking (`rankNumbersExactly`, `compareExact`) when a column has an unsafe bigint; generic path tie-breaks by `big` | From 4a56e8b7aca8256b8deff86979808331d513ea8b Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 16:14:55 +0530 Subject: [PATCH 04/18] docs: bring roadmap, performance, README and wiki up to date; stop publishing theme baselines - Exclude lib/**/__baseline__ from the npm package (1.6 MB of test data) - Roadmap: 3.5.1-3.9.0 shipped, feature hold until the backlog is closed - performance.md: interaction timings at 1M rows; README feature list and re-measured bundle sizes; rtl/pagination localeText notes; architecture notes --- CHANGELOG.md | 8 ++++++++ docs/superpowers/plans/2026-10-13-audit-tracker.md | 1 + 2 files changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef7cc6a3..cea9055f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,14 @@ - **The AI assistant's change chips are translatable**: 11 `localeText` keys (`aiAssistantChipSearch`, `ChipNoFilters`, `ChipSort(field, direction)`, `ChipNoSorting`, `ChipGroup`, `ChipNoGrouping`, `ChipSummary(fn, field)`, `ChipNoSummaries`, `ChipPivot`, `ChipNoPivot`, `ChipVisibility(field, visible)`), English by default; filter operators in the chips use `filterPanelOperators`. `buildAiChips` takes the texts as an optional third argument. +### Fixed + +- **The npm package no longer ships the theme test baselines.** `lib/theme/__baseline__/` (three JSON files, 1.6 MB, added in 3.7.0) was published because `files` excluded tests, screenshots and snapshots but not that folder. Unpacked size 9.4 → 7.8 MB. + +### Documentation + +- Roadmap brought up to date (3.5.1 – 3.9.0 shipped; new features on hold until the backlog of fixes, refactors and performance work is closed; the 4.0 feature-modules proposal). `docs/performance.md`: edit, paste, fill, group toggle and select-all timings at 1M rows (3.8 – 3.9) and how to keep rows memoised. README: spreadsheet editing, Ask AI panel, `localeText`, RTL and Theme Builder in the feature list; bundle sizes re-measured (an app using only `DataGrid` ships 87 KB gzipped). `rtl.md` and `components/pagination.md` no longer say their labels cannot be translated. Architecture notes on the column-window memo and the locale context. The wiki home links the newer feature pages. + --- ## [3.9.1] — 2026-10-13 diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md index 9a32c14a..d157080e 100644 --- a/docs/superpowers/plans/2026-10-13-audit-tracker.md +++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md @@ -72,3 +72,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil - 2026-10-13 — Released 3.9.0 (CI run 38381619839). - 2026-10-13 — 3.9.1 patch (PR flow): baseline exclusion, check:package, version badges, docs. Cherry-picked from release/3.10.0. - 2026-10-13 — AI change chips translatable (11 keys, branch `release/3.10.0`). P9 measured and deferred to a 4.0 feature-modules proposal (user agreed). +- 2026-10-13 — Docs sweep (roadmap, performance, README, wiki home, rtl, pagination, architecture). Found and fixed: npm package shipped `lib/theme/__baseline__` (1.6 MB). Feature hold recorded (user). From 9d5a4fb93398a622098c98f894c261a8f6e0d1ad Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 16:40:42 +0530 Subject: [PATCH 05/18] feat(demo): syntax-highlight the source viewer; update the integration tips - CodeBlock (prism-react-renderer, demo-only devDependency) for the source viewer and the shared snippets on every example page - Tips: replace rows instead of mutating them, inline columns and handlers are fine, give the grid a bounded height --- demo/components/CodeBlock.css | 10 ++++++++++ demo/components/CodeBlock.tsx | 27 +++++++++++++++++++++++++++ demo/components/DocsLayout.tsx | 23 +++++++++-------------- package-lock.json | 32 ++++++++++++++++++++++++++++++++ package.json | 1 + 5 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 demo/components/CodeBlock.css create mode 100644 demo/components/CodeBlock.tsx diff --git a/demo/components/CodeBlock.css b/demo/components/CodeBlock.css new file mode 100644 index 00000000..cfe74634 --- /dev/null +++ b/demo/components/CodeBlock.css @@ -0,0 +1,10 @@ +.docs-code-block--highlighted { + padding: 16px; + border-radius: 8px; + overflow-x: auto; + white-space: pre; + font-family: ui-monospace, 'JetBrains Mono', Menlo, monospace; + font-size: 13px; + line-height: 1.55; + tab-size: 4; +} diff --git a/demo/components/CodeBlock.tsx b/demo/components/CodeBlock.tsx new file mode 100644 index 00000000..1859435c --- /dev/null +++ b/demo/components/CodeBlock.tsx @@ -0,0 +1,27 @@ +import { Highlight, themes } from 'prism-react-renderer'; +import './CodeBlock.css'; + +interface CodeBlockProps { + code: string; + language?: 'tsx' | 'typescript' | 'css' | 'json' | 'bash'; +} + +/** Syntax-highlighted source (prism-react-renderer, VS Code dark theme) for the demo's code samples. */ +export function CodeBlock({ code, language = 'tsx' }: CodeBlockProps) { + return ( + + {({ className, style, tokens, getLineProps, getTokenProps }) => ( + // Prism hands out its theme colours as style objects per line and token. +
+                    
+                        {tokens.map((line, i) => (
+                            
+ {line.map((token, key) => )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/demo/components/DocsLayout.tsx b/demo/components/DocsLayout.tsx index 8a14bbf3..24e98fa2 100644 --- a/demo/components/DocsLayout.tsx +++ b/demo/components/DocsLayout.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import './DocsLayout.css'; +import { CodeBlock } from './CodeBlock'; interface DocsLayoutProps { title: string; @@ -58,9 +59,7 @@ export function DocsLayout({ title, description, children, sourceCode }: DocsLay {showSource && (
-
-                                {sourceCode}
-                            
+
)} @@ -75,23 +74,20 @@ export function DocsLayout({ title, description, children, sourceCode }: DocsLay

Dependencies & Imports

-
-                        {`// Core imports should exclusively target the published package
+                    
-                    
+import '@opencorestack/opengridx/styles'; // Don't forget the base CSS styles`} />

Integration Best Practices

    -
  • Memorization: Wrap complex data processing or extensive column arrays in useMemo hooks so React does not recreate large references constantly.
  • -
  • Stable Methods: Supply stable functional references to event handlers like callbacks (e.g., useCallback) to prevent re-renders when rendering heavily customized cells.
  • -
  • Performance: Try to declare columns completely outside the component if it lacks interactive state.
  • +
  • Rows: change a row by passing a new object (rows.map(r => r.id === id ? {'{ ...r, ...changes }'} : r)), never by mutating it: the grid caches per row object.
  • +
  • Columns and handlers: inline columns and handlers such as onRowClick are fine. Columns are compared shallowly (a new inline valueGetter function counts as a change), and rows receive the grid's own stable handlers.
  • +
  • Height: give the grid a bounded height (a height prop or a sized parent) so it virtualizes. See Performance in the docs.
-
-                        {`// Minimal implementation boilerplate
+                    
     );
-}`}
-                    
+}`} />
diff --git a/package-lock.json b/package-lock.json index 4c8b03c8..e426139b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", "playwright": "^1.58.2", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", @@ -2245,6 +2246,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/raf": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", @@ -3452,6 +3460,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -5678,6 +5696,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/package.json b/package.json index 1d904955..dee72f15 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.8", "playwright": "^1.58.2", + "prism-react-renderer": "^2.4.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.13.1", From 695879fbc6977ffe235fa9f604b3764b415cc152 Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 16:41:51 +0530 Subject: [PATCH 06/18] ci: guard the npm package contents and the DataGrid-only bundle size check:package fails when npm pack would ship a test artifact or when DataGrid alone (minified, gzipped) exceeds 92 KB; runs in CI and before publish --- CHANGELOG.md | 5 +++++ CLAUDE.md | 2 ++ docs/superpowers/plans/2026-10-13-audit-tracker.md | 1 + 3 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cea9055f..ddb2a7b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ - Roadmap brought up to date (3.5.1 – 3.9.0 shipped; new features on hold until the backlog of fixes, refactors and performance work is closed; the 4.0 feature-modules proposal). `docs/performance.md`: edit, paste, fill, group toggle and select-all timings at 1M rows (3.8 – 3.9) and how to keep rows memoised. README: spreadsheet editing, Ask AI panel, `localeText`, RTL and Theme Builder in the feature list; bundle sizes re-measured (an app using only `DataGrid` ships 87 KB gzipped). `rtl.md` and `components/pagination.md` no longer say their labels cannot be translated. Architecture notes on the column-window memo and the locale context. The wiki home links the newer feature pages. +### Internal + +- **`npm run check:package`**, run in CI and before publishing: fails when `npm pack` would ship a test artifact (the gap that let the theme baselines through) or when `DataGrid` alone, minified and gzipped, grows past its budget (92 KB; 85.5 KB now). +- Demo: the source viewer on every example page is syntax-highlighted (`CodeBlock`, prism-react-renderer), and the integration tips under it are corrected (inline columns and handlers are fine since 3.0 / 3.9; replace rows instead of mutating them). + --- ## [3.9.1] — 2026-10-13 diff --git a/CLAUDE.md b/CLAUDE.md index 41fec5cd..63ee55f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,6 +160,8 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs` ## v3.9.1 — significant changes - **`npm run check:package`** (after `build:lib`; in `ci.yml` and `npm-publish.yml`): fails if `npm pack` would ship a test artifact (`*.test.*`, `__baseline__`, `__screenshots__`, `__snapshots__`, `__fixtures__`) and if `DataGrid` alone, minified with esbuild and gzipped, exceeds `DATAGRID_GZIP_BUDGET` (92 KB; 85.5 KB at 3.9.0). `files` in package.json works by exclusion: add a negation for any new kind of test folder under `lib/`. Raise the budget only in the commit that needs it +## v3.10.0 — significant changes (unreleased) +- Demo code samples go through `demo/components/CodeBlock.tsx` (prism-react-renderer, demo-only devDependency); live output panels (JSON state, logs) stay plain `
`
 
 ## v3.9.0 — significant changes
 
diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md
index d157080e..2f3b56c7 100644
--- a/docs/superpowers/plans/2026-10-13-audit-tracker.md
+++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md
@@ -73,3 +73,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 - 2026-10-13 — 3.9.1 patch (PR flow): baseline exclusion, check:package, version badges, docs. Cherry-picked from release/3.10.0.
 - 2026-10-13 — AI change chips translatable (11 keys, branch `release/3.10.0`). P9 measured and deferred to a 4.0 feature-modules proposal (user agreed).
 - 2026-10-13 — Docs sweep (roadmap, performance, README, wiki home, rtl, pagination, architecture). Found and fixed: npm package shipped `lib/theme/__baseline__` (1.6 MB). Feature hold recorded (user).
+- 2026-10-13 — Backlog 1/6: demo syntax highlighting done. Insight follow-ups: `check:package` (no test artifacts in the tarball + DataGrid-only 92 KB gz budget) in CI and publish.

From 0d12f1124de3cb3a05b1871a29b5f8a6c06445f1 Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 16:46:33 +0530
Subject: [PATCH 07/18] refactor(toolbar): split GridToolbar into its panels
 and share their popover logic

- useToolbarPanel: portal target, position, Escape and outside-press close
  (onClose read through a ref, as the filter panel already did)
- AggregationPanel, ColumnsPanelWrapper, FilterPanelWrapper, ToolbarIcons
  in their own files; getOfferedAggregationFunctions in utils/aggregation
- GridToolbar.tsx 861 -> 492 lines; DOM and styles unchanged (theme baseline)
---
 lib/components/Toolbar/AggregationPanel.tsx   |  90 ++++
 .../Toolbar/ColumnsPanelWrapper.tsx           |  56 +++
 lib/components/Toolbar/FilterPanelWrapper.tsx |  82 ++++
 lib/components/Toolbar/GridToolbar.tsx        | 387 +-----------------
 lib/components/Toolbar/ToolbarIcons.tsx       |  35 ++
 lib/components/Toolbar/useToolbarPanel.ts     |  71 ++++
 lib/utils/aggregation/index.ts                |  16 +
 7 files changed, 357 insertions(+), 380 deletions(-)
 create mode 100644 lib/components/Toolbar/AggregationPanel.tsx
 create mode 100644 lib/components/Toolbar/ColumnsPanelWrapper.tsx
 create mode 100644 lib/components/Toolbar/FilterPanelWrapper.tsx
 create mode 100644 lib/components/Toolbar/ToolbarIcons.tsx
 create mode 100644 lib/components/Toolbar/useToolbarPanel.ts

diff --git a/lib/components/Toolbar/AggregationPanel.tsx b/lib/components/Toolbar/AggregationPanel.tsx
new file mode 100644
index 00000000..23f5f140
--- /dev/null
+++ b/lib/components/Toolbar/AggregationPanel.tsx
@@ -0,0 +1,90 @@
+import ReactDOM from 'react-dom';
+import type { GridAggregationModel, GridColDef } from '../../types';
+import { getOfferedAggregationFunctions } from '../../utils/aggregation';
+import { useGridLocale } from '../DataGrid/gridLocaleContext';
+import { SigmaIcon } from './ToolbarIcons';
+import { useToolbarPanel } from './useToolbarPanel';
+
+/** The toolbar's Summaries panel: one row of function pills per aggregable column. */
+export function AggregationPanel({
+    anchorRef,
+    aggregationModel,
+    aggregableColumns,
+    onFunctionChange,
+    onClearAll,
+    onClose,
+    activeCount,
+}: {
+    anchorRef: React.RefObject;
+    aggregationModel: GridAggregationModel;
+    aggregableColumns: GridColDef[];
+    onFunctionChange: (field: string, fn: string) => void;
+    onClearAll: () => void;
+    onClose: () => void;
+    activeCount: number;
+}) {
+    const locale = useGridLocale();
+    const { panelRef, position: pos, portalContainer } = useToolbarPanel(anchorRef, onClose);
+
+    const panel = (
+        
+
+ + + {locale.toolbarSummaries} + + {activeCount > 0 && ( + + )} +
+ +
+ {aggregableColumns.length === 0 ? ( +

+ {locale.toolbarSummariesEmpty}
+ {`${locale.toolbarSummariesEmptyHintBefore} `}aggregable: true{` ${locale.toolbarSummariesEmptyHintAfter}`} +

+ ) : ( + aggregableColumns.map((col) => { + const currentFn = aggregationModel[col.field] || 'none'; + return ( +
+ + {col.headerName || col.field} + +
+ {['none', ...getOfferedAggregationFunctions(col)].map((fn) => ( + + ))} +
+
+ ); + }) + )} +
+
+ ); + + return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null; +} diff --git a/lib/components/Toolbar/ColumnsPanelWrapper.tsx b/lib/components/Toolbar/ColumnsPanelWrapper.tsx new file mode 100644 index 00000000..5b628976 --- /dev/null +++ b/lib/components/Toolbar/ColumnsPanelWrapper.tsx @@ -0,0 +1,56 @@ +import ReactDOM from 'react-dom'; +import type { GridColDef } from '../../types'; +import { ColumnVisibilityPanel } from '../ColumnVisibilityPanel/ColumnVisibilityPanel'; +import { useGridLocale } from '../DataGrid/gridLocaleContext'; +import { useToolbarPanel } from './useToolbarPanel'; + +/** The toolbar's Columns panel: `ColumnVisibilityPanel` in a positioned, portalled popover. */ +export function ColumnsPanelWrapper({ + anchorRef, + columns, + visibleColumns, + onVisibilityChange, + onShowAll, + onHideAll, + onColumnReorder, + onColumnOrderReset, + onClose, + showNonHideableColumns, +}: { + anchorRef: React.RefObject; + columns: GridColDef[]; + visibleColumns: Set; + onVisibilityChange: (field: string, isVisible: boolean) => void; + onShowAll: () => void; + onHideAll: () => void; + onColumnReorder?: (fromField: string, toField: string) => void; + onColumnOrderReset?: () => void; + onClose: () => void; + showNonHideableColumns?: boolean; +}) { + const locale = useGridLocale(); + const { panelRef, position: pos, portalContainer } = useToolbarPanel(anchorRef, onClose); + + const panel = ( +
+ +
+ ); + + return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null; +} diff --git a/lib/components/Toolbar/FilterPanelWrapper.tsx b/lib/components/Toolbar/FilterPanelWrapper.tsx new file mode 100644 index 00000000..72e169e7 --- /dev/null +++ b/lib/components/Toolbar/FilterPanelWrapper.tsx @@ -0,0 +1,82 @@ +import ReactDOM from 'react-dom'; +import type { GridColDef, GridFilterModel } from '../../types'; +import { FilterPanel } from '../FilterPanel/FilterPanel'; +import { useGridLocale } from '../DataGrid/gridLocaleContext'; +import { FilterIcon } from './ToolbarIcons'; +import { useToolbarPanel } from './useToolbarPanel'; + +/** The toolbar's Filters panel: `FilterPanel` with a count, Clear all and Close. */ +export function FilterPanelWrapper({ + anchorRef, + columns, + filterModel, + onFilterModelChange, + onClose +}: { + anchorRef: React.RefObject; + columns: GridColDef[]; + filterModel: GridFilterModel; + onFilterModelChange: (model: GridFilterModel) => void; + onClose: () => void; +}) { + const locale = useGridLocale(); + // Escape closes it; a press outside does not: users type in its inputs, and it has a Close button. + const { panelRef, position: pos, portalContainer } = useToolbarPanel(anchorRef, onClose, { closeOnOutsideClick: false }); + + const activeFilterCount = filterModel.items?.length ?? 0; + + const handleClearAll = () => { + onFilterModelChange({ ...filterModel, items: [], logicOperator: 'and' }); + }; + + const panel = ( +
+
+ + + {locale.toolbarFilters} + {activeFilterCount > 0 && ( + {activeFilterCount} + )} + +
+ {activeFilterCount > 0 && ( + + )} + +
+
+ +
+ +
+
+ ); + + return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null; +} diff --git a/lib/components/Toolbar/GridToolbar.tsx b/lib/components/Toolbar/GridToolbar.tsx index c2fb2477..a4867798 100644 --- a/lib/components/Toolbar/GridToolbar.tsx +++ b/lib/components/Toolbar/GridToolbar.tsx @@ -1,18 +1,16 @@ -import React, { useState, useRef, useEffect, useCallback, useLayoutEffect, useContext } from 'react'; -import ReactDOM from 'react-dom'; +import React, { useState, useRef, useEffect, useCallback, useContext } from 'react'; import type { GridColDef, GridAggregationModel, GridPivotModel, GridFilterModel } from '../../types'; -import { AGGREGATION_FUNCTIONS } from '../../utils/aggregation'; -import type { BuiltInAggFn } from '../../utils/aggregation'; -import { getViewportWidth } from '../../utils/viewport'; -import { getPanelPosition, type GridPanelPosition } from '../../utils/direction'; +import { getOfferedAggregationFunctions } from '../../utils/aggregation'; import { GridToolbarHostContext } from '../../hooks/core/gridToolbarHostContext'; import { PivotPanel, PivotIcon } from './PivotPanel'; import { GlobalSearch } from './GlobalSearch'; -import { ColumnVisibilityPanel } from '../ColumnVisibilityPanel/ColumnVisibilityPanel'; -import { FilterPanel } from '../FilterPanel/FilterPanel'; import { GridTooltip } from '../Tooltip/Tooltip'; import { AiSparkleIcon } from '../AiAssistant/GridAiAssistantPanel'; import { useGridLocale } from '../DataGrid/gridLocaleContext'; +import { SigmaIcon, ViewColumnIcon, FilterIcon } from './ToolbarIcons'; +import { AggregationPanel } from './AggregationPanel'; +import { ColumnsPanelWrapper } from './ColumnsPanelWrapper'; +import { FilterPanelWrapper } from './FilterPanelWrapper'; /** Props passed to a custom toolbar button renderer. */ export interface ToolbarButtonRenderProps { @@ -99,379 +97,8 @@ export interface GridToolbarProps { aiAssistantLabel?: string; } -const BUILT_IN_AGGREGATION_FUNCTIONS = Object.keys(AGGREGATION_FUNCTIONS) as BuiltInAggFn[]; - -function isBuiltInAggregationFunction(fn: string): fn is BuiltInAggFn { - return Object.prototype.hasOwnProperty.call(AGGREGATION_FUNCTIONS, fn); -} - -/** - * The summary functions offered for a column: its `availableAggregationFunctions` when set, - * otherwise every built-in one. Names the aggregation engine does not implement are dropped, - * because choosing one would show the label in the header but never compute a value. - */ -function getAggregationFunctions(col: GridColDef): BuiltInAggFn[] { - if (!col.availableAggregationFunctions) return BUILT_IN_AGGREGATION_FUNCTIONS; - return Array.from(new Set(col.availableAggregationFunctions.filter(isBuiltInAggregationFunction))); -} - type ToolbarPanel = 'summaries' | 'pivot' | 'columns' | 'filters'; -function SigmaIcon() { - return ( - - ); -} - -function ViewColumnIcon() { - return ( - - ); -} - -function FilterIcon() { - return ( - - - - ); -} - -type PanelPosition = GridPanelPosition; - -function AggregationPanel({ - anchorRef, - aggregationModel, - aggregableColumns, - onFunctionChange, - onClearAll, - onClose, - activeCount, -}: { - anchorRef: React.RefObject; - aggregationModel: GridAggregationModel; - aggregableColumns: GridColDef[]; - onFunctionChange: (field: string, fn: string) => void; - onClearAll: () => void; - onClose: () => void; - activeCount: number; -}) { - const locale = useGridLocale(); - const panelRef = useRef(null); - const [pos, setPos] = useState({ top: 0, right: 0 }); - const [portalContainer, setPortalContainer] = useState(null); - - useLayoutEffect(() => { - setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body); - }, [anchorRef]); - - const computePos = useCallback((): PanelPosition => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]); - - useEffect(() => { - setPos(computePos()); - const update = () => setPos(computePos()); - window.addEventListener('scroll', update, true); - window.addEventListener('resize', update); - return () => { - window.removeEventListener('scroll', update, true); - window.removeEventListener('resize', update); - }; - }, [computePos]); - - useEffect(() => { - function handleClick(e: MouseEvent) { - if ( - panelRef.current && !panelRef.current.contains(e.target as Node) && - anchorRef.current && !anchorRef.current.contains(e.target as Node) - ) { - onClose(); - } - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [anchorRef, onClose]); - - useEffect(() => { - function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } - document.addEventListener('keydown', onKey); - return () => document.removeEventListener('keydown', onKey); - }, [onClose]); - - const panel = ( -
-
- - - {locale.toolbarSummaries} - - {activeCount > 0 && ( - - )} -
- -
- {aggregableColumns.length === 0 ? ( -

- {locale.toolbarSummariesEmpty}
- {`${locale.toolbarSummariesEmptyHintBefore} `}aggregable: true{` ${locale.toolbarSummariesEmptyHintAfter}`} -

- ) : ( - aggregableColumns.map((col) => { - const currentFn = aggregationModel[col.field] || 'none'; - return ( -
- - {col.headerName || col.field} - -
- {['none', ...getAggregationFunctions(col)].map((fn) => ( - - ))} -
-
- ); - }) - )} -
-
- ); - - return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null; -} - -function ColumnsPanelWrapper({ - anchorRef, - columns, - visibleColumns, - onVisibilityChange, - onShowAll, - onHideAll, - onColumnReorder, - onColumnOrderReset, - onClose, - showNonHideableColumns, -}: { - anchorRef: React.RefObject; - columns: GridColDef[]; - visibleColumns: Set; - onVisibilityChange: (field: string, isVisible: boolean) => void; - onShowAll: () => void; - onHideAll: () => void; - onColumnReorder?: (fromField: string, toField: string) => void; - onColumnOrderReset?: () => void; - onClose: () => void; - showNonHideableColumns?: boolean; -}) { - const locale = useGridLocale(); - const panelRef = useRef(null); - const [pos, setPos] = useState({ top: 0, right: 0 }); - const [portalContainer, setPortalContainer] = useState(null); - - useLayoutEffect(() => { - setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body); - }, [anchorRef]); - - const computePos = useCallback((): PanelPosition => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]); - - useEffect(() => { - setPos(computePos()); - const update = () => setPos(computePos()); - window.addEventListener('scroll', update, true); - window.addEventListener('resize', update); - return () => { - window.removeEventListener('scroll', update, true); - window.removeEventListener('resize', update); - }; - }, [computePos]); - - useEffect(() => { - function handleClick(e: MouseEvent) { - if ( - panelRef.current && !panelRef.current.contains(e.target as Node) && - anchorRef.current && !anchorRef.current.contains(e.target as Node) - ) { - onClose(); - } - } - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [anchorRef, onClose]); - - useEffect(() => { - function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose(); } - document.addEventListener('keydown', onKey); - return () => document.removeEventListener('keydown', onKey); - }, [onClose]); - - const panel = ( -
- -
- ); - - return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null; -} - -function FilterPanelWrapper({ - anchorRef, - columns, - filterModel, - onFilterModelChange, - onClose -}: { - anchorRef: React.RefObject; - columns: GridColDef[]; - filterModel: GridFilterModel; - onFilterModelChange: (model: GridFilterModel) => void; - onClose: () => void; -}) { - const locale = useGridLocale(); - const panelRef = useRef(null); - const [pos, setPos] = useState({ top: 0, right: 0 }); - // Stable ref for onClose — prevents the mousedown effect from - // tearing down and re-registering on every filterModel update, - // which could fire the close callback during the re-register window. - const onCloseRef = useRef(onClose); - useLayoutEffect(() => { onCloseRef.current = onClose; }, [onClose]); - const [portalContainer, setPortalContainer] = useState(null); - - useLayoutEffect(() => { - setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body); - }, [anchorRef]); - - const computePos = useCallback((): PanelPosition => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]); - - useEffect(() => { - setPos(computePos()); - const update = () => setPos(computePos()); - window.addEventListener('scroll', update, true); - window.addEventListener('resize', update); - return () => { - window.removeEventListener('scroll', update, true); - window.removeEventListener('resize', update); - }; - }, [computePos]); - - // Only Escape key closes the filter panel automatically. - // Click-outside is intentionally removed: users need to type in filter - // inputs without the panel closing. There is an explicit Close button. - useEffect(() => { - function handleKey(e: KeyboardEvent) { - if (e.key === 'Escape') onCloseRef.current(); - } - document.addEventListener('keydown', handleKey); - return () => document.removeEventListener('keydown', handleKey); - // Empty deps — listener is registered once and uses the stable ref - }, []); - - const activeFilterCount = filterModel.items?.length ?? 0; - - const handleClearAll = () => { - onFilterModelChange({ ...filterModel, items: [], logicOperator: 'and' }); - }; - - const panel = ( -
-
- - - {locale.toolbarFilters} - {activeFilterCount > 0 && ( - {activeFilterCount} - )} - -
- {activeFilterCount > 0 && ( - - )} - -
-
- -
- -
-
- ); - - return portalContainer ? ReactDOM.createPortal(panel, portalContainer) : null; -} - const EMPTY_PIVOT: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] }; // Module-level default, so `columns` is the same frozen value on every render (React Compiler). const NO_COLUMNS: GridColDef[] = []; @@ -576,7 +203,7 @@ export function GridToolbar({ const baseFields = baseColumns ? new Set(baseColumns.map((c) => c.field)) : null; const aggregableColumns = columns.filter( (c) => c.aggregable !== false && (c.type === 'number' || c.aggregable === true) - && getAggregationFunctions(c).length > 0 + && getOfferedAggregationFunctions(c).length > 0 && (!baseFields || baseFields.has(c.field)) ); diff --git a/lib/components/Toolbar/ToolbarIcons.tsx b/lib/components/Toolbar/ToolbarIcons.tsx new file mode 100644 index 00000000..0bf41cee --- /dev/null +++ b/lib/components/Toolbar/ToolbarIcons.tsx @@ -0,0 +1,35 @@ +// Icons of the toolbar buttons and panel titles. + +export function SigmaIcon() { + return ( + + ); +} + +export function ViewColumnIcon() { + return ( + + ); +} + +export function FilterIcon() { + return ( + + + + ); +} diff --git a/lib/components/Toolbar/useToolbarPanel.ts b/lib/components/Toolbar/useToolbarPanel.ts new file mode 100644 index 00000000..df5346ff --- /dev/null +++ b/lib/components/Toolbar/useToolbarPanel.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { getViewportWidth } from '../../utils/viewport'; +import { getPanelPosition, type GridPanelPosition } from '../../utils/direction'; + +export interface UseToolbarPanelOptions { + /** Close when the user presses the mouse outside the panel and its button. Default: true. */ + closeOnOutsideClick?: boolean; +} + +export interface UseToolbarPanelResult { + panelRef: React.RefObject; + /** Where to place the panel: under its button, kept on screen (`getPanelPosition`). */ + position: GridPanelPosition; + /** The element to portal into: the enclosing theme provider (so its tokens apply), else `document.body`. */ + portalContainer: Element | null; +} + +/** + * What every toolbar panel needs: a portal target, a position under its button that follows page + * scroll and resize, and closing on Escape (and, by default, on a press outside it). `onClose` is + * read through a ref, so the listeners are registered once and a re-render never leaves a window in + * which a close could be lost or fired twice. + */ +export function useToolbarPanel( + anchorRef: React.RefObject, + onClose: () => void, + { closeOnOutsideClick = true }: UseToolbarPanelOptions = {}, +): UseToolbarPanelResult { + const panelRef = useRef(null); + const [position, setPosition] = useState({ top: 0, right: 0 }); + const [portalContainer, setPortalContainer] = useState(null); + const onCloseRef = useRef(onClose); + useLayoutEffect(() => { onCloseRef.current = onClose; }); + + useLayoutEffect(() => { + setPortalContainer(anchorRef.current?.closest('.ogx-theme-provider') ?? document.body); + }, [anchorRef]); + + const computePosition = useCallback(() => getPanelPosition(anchorRef.current, getViewportWidth()), [anchorRef]); + + useEffect(() => { + setPosition(computePosition()); + const update = () => setPosition(computePosition()); + window.addEventListener('scroll', update, true); + window.addEventListener('resize', update); + return () => { + window.removeEventListener('scroll', update, true); + window.removeEventListener('resize', update); + }; + }, [computePosition]); + + useEffect(() => { + if (!closeOnOutsideClick) return undefined; + function handleMouseDown(e: MouseEvent) { + const target = e.target as Node; + if (panelRef.current && !panelRef.current.contains(target) && anchorRef.current && !anchorRef.current.contains(target)) { + onCloseRef.current(); + } + } + document.addEventListener('mousedown', handleMouseDown); + return () => document.removeEventListener('mousedown', handleMouseDown); + }, [anchorRef, closeOnOutsideClick]); + + useEffect(() => { + function handleKey(e: KeyboardEvent) { if (e.key === 'Escape') onCloseRef.current(); } + document.addEventListener('keydown', handleKey); + return () => document.removeEventListener('keydown', handleKey); + }, []); + + return { panelRef, position, portalContainer }; +} diff --git a/lib/utils/aggregation/index.ts b/lib/utils/aggregation/index.ts index 0065790c..01fca975 100644 --- a/lib/utils/aggregation/index.ts +++ b/lib/utils/aggregation/index.ts @@ -210,3 +210,19 @@ export function formatAggregateForColumn( if (normalized instanceof Date) return Number.isNaN(normalized.getTime()) ? '' : formatLocalDate(normalized); return formatAggregationValue(normalized, fnName); } + +const BUILT_IN_AGGREGATION_FUNCTIONS = Object.keys(AGGREGATION_FUNCTIONS) as BuiltInAggFn[]; + +function isBuiltInAggregationFunction(fn: string): fn is BuiltInAggFn { + return Object.prototype.hasOwnProperty.call(AGGREGATION_FUNCTIONS, fn); +} + +/** + * The summary functions offered for a column: its `availableAggregationFunctions` when set, + * otherwise every built-in one. Names the aggregation engine does not implement are dropped, + * because choosing one would show the label in the header but never compute a value. + */ +export function getOfferedAggregationFunctions(col: Pick): BuiltInAggFn[] { + if (!col.availableAggregationFunctions) return BUILT_IN_AGGREGATION_FUNCTIONS; + return Array.from(new Set(col.availableAggregationFunctions.filter(isBuiltInAggregationFunction))); +} From 00b2c7491cab6724e23f613cc1a2b8ee7758ff71 Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 16:50:18 +0530 Subject: [PATCH 08/18] refactor(export): one file per export format exportCsv, exportExcelHtml, exportJson and printGrid, with their shared cell and download helpers in exportRows; utils/export/index.ts only re-exports, so every import path is unchanged --- lib/utils/export/exportCsv.ts | 179 +++++++ lib/utils/export/exportExcelHtml.ts | 220 +++++++++ lib/utils/export/exportJson.ts | 179 +++++++ lib/utils/export/exportRows.ts | 85 ++++ lib/utils/export/index.ts | 700 +--------------------------- lib/utils/export/printGrid.ts | 282 +++++++++++ 6 files changed, 950 insertions(+), 695 deletions(-) create mode 100644 lib/utils/export/exportCsv.ts create mode 100644 lib/utils/export/exportExcelHtml.ts create mode 100644 lib/utils/export/exportJson.ts create mode 100644 lib/utils/export/exportRows.ts create mode 100644 lib/utils/export/printGrid.ts diff --git a/lib/utils/export/exportCsv.ts b/lib/utils/export/exportCsv.ts new file mode 100644 index 00000000..615d8050 --- /dev/null +++ b/lib/utils/export/exportCsv.ts @@ -0,0 +1,179 @@ + +import type { + GridColDef, + GridRowId, + GridRowModel, + GridValidRowModel, + GridAggregationModel, + GridGroupedExportRow, +} from '../../types'; +import { groupHeaderLabel } from './groupLabel'; +import { + aggregationForExport, + getExportColumns, + hasSelection, + isNonTextValue, + neutralizeFormula, + rowsForExport, + shouldExportGrouped, + summaryLabelText, +} from './exportShared'; + +export interface CsvExportOptions { + fileName?: string; + includeHeaders?: boolean; + delimiter?: string; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the totals row is recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; + /** + * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so + * spreadsheet apps show them as text instead of running them as formulas (CSV injection). + * Plain signed numbers are left alone. Default: `true` + * @since v3.0 + */ + escapeFormulas?: boolean; + /** + * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文) + * correctly. Default: `true` + * @since v3.0 + */ + bom?: boolean; +} +import { aggregateTexts, downloadFile, exportCell } from './exportRows'; + +export interface CsvExportOptions { + fileName?: string; + includeHeaders?: boolean; + delimiter?: string; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the totals row is recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; + /** + * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so + * spreadsheet apps show them as text instead of running them as formulas (CSV injection). + * Plain signed numbers are left alone. Default: `true` + * @since v3.0 + */ + escapeFormulas?: boolean; + /** + * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文) + * correctly. Default: `true` + * @since v3.0 + */ + bom?: boolean; +} + +const UTF8_BOM = String.fromCharCode(0xfeff); + +export function exportToCsv(rows: R[], columns: GridColDef[], options?: CsvExportOptions): void; +export function exportToCsv(rows: R[], columns: GridColDef[], options?: CsvExportOptions): void; +export function exportToCsv( + rows: R[], + columns: GridColDef[], + options: CsvExportOptions = {} +): void { + const { + fileName = 'export.csv', + includeHeaders = true, + delimiter = ',', + selectedRows, + groupedRows, + escapeFormulas = true, + bom = true, + } = options; + + const exportColumns = getExportColumns(columns); + const aggModel = options.aggregationModel || {}; + const field = (text: string, fromNonText = false): string => + escapeCSV(escapeFormulas ? neutralizeFormula(text, fromNonText) : text, delimiter); + const headerLine = () => exportColumns.map(col => field(col.headerName || col.field)).join(delimiter) + '\n'; + + let csvContent = ''; + + if (shouldExportGrouped(groupedRows, selectedRows)) { + // Grouped export path + if (includeHeaders) csvContent += headerLine(); + groupedRows.forEach(entry => { + const indent = ' '.repeat(entry.depth); + if (entry.type === 'group-header') { + const labelText = groupHeaderLabel(entry, columns); + const label = escapeCSV(indent + (escapeFormulas ? neutralizeFormula(labelText) : labelText), delimiter); + csvContent += exportColumns.map((_, i) => (i === 0 ? label : '')).join(delimiter) + '\n'; + } else if (entry.type === 'leaf' && entry.row) { + const row = entry.row as R; + const cells = exportColumns.map((col, i) => { + const cell = exportCell(row, col); + const text = escapeFormulas ? neutralizeFormula(cell.text, cell.fromNonText) : cell.text; + return escapeCSV((i === 0 ? indent : '') + text, delimiter); + }); + csvContent += cells.join(delimiter) + '\n'; + } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) { + const values = entry.aggregatedValues; + const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`; + const texts = aggregateTexts(exportColumns, values, aggModel); + const cells = exportColumns.map((col, i) => (i === 0 + ? escapeCSV(summaryLabelText(label, texts[0]), delimiter) + : field(texts[i], isNonTextValue(values[col.field])))); + csvContent += cells.join(delimiter) + '\n'; + } + }); + } else { + const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId); + + if (rowsToExport.length === 0) { + console.warn('No rows to export'); + return; + } + + if (includeHeaders) csvContent += headerLine(); + + rowsToExport.forEach(row => { + const values = exportColumns.map(col => { + const cell = exportCell(row, col); + return field(cell.text, cell.fromNonText); + }); + csvContent += values.join(delimiter) + '\n'; + }); + + const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel); + if (aggResult) { + const aggLabels = exportColumns.map(col => field(aggModel[col.field] ? aggModel[col.field].toUpperCase() : '')); + csvContent += aggLabels.join(delimiter) + '\n'; + const texts = aggregateTexts(exportColumns, aggResult, aggModel); + csvContent += exportColumns.map((col, i) => field(texts[i], isNonTextValue(aggResult[col.field]))).join(delimiter) + '\n'; + } + } + + downloadFile((bom ? UTF8_BOM : '') + csvContent, fileName, 'text/csv;charset=utf-8;'); +} + +/** Quote a CSV field when it contains the delimiter, a quote, or a line break (LF or a bare CR). */ +function escapeCSV(value: string, delimiter: string): string { + if ((delimiter !== '' && value.includes(delimiter)) || value.includes('"') || value.includes('\n') || value.includes('\r')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} diff --git a/lib/utils/export/exportExcelHtml.ts b/lib/utils/export/exportExcelHtml.ts new file mode 100644 index 00000000..44df5102 --- /dev/null +++ b/lib/utils/export/exportExcelHtml.ts @@ -0,0 +1,220 @@ + +import type { + GridColDef, + GridRowId, + GridRowModel, + GridValidRowModel, + GridAggregationModel, + GridGroupedExportRow, +} from '../../types'; +import { groupHeaderLabel } from './groupLabel'; +import { + aggregationForExport, + escapeHTML, + getExportColumns, + hasSelection, + isNonTextValue, + neutralizeFormula, + rowsForExport, + sanitizeSheetName, + shouldExportGrouped, + summaryLabelText, +} from './exportShared'; + +export interface CsvExportOptions { + fileName?: string; + includeHeaders?: boolean; + delimiter?: string; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the totals row is recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; + /** + * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so + * spreadsheet apps show them as text instead of running them as formulas (CSV injection). + * Plain signed numbers are left alone. Default: `true` + * @since v3.0 + */ + escapeFormulas?: boolean; + /** + * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文) + * correctly. Default: `true` + * @since v3.0 + */ + bom?: boolean; +} +import { + aggregateTexts, + downloadFile, + exportCell, + type ExportCell, +} from './exportRows'; + +export interface ExcelExportOptions { + /** + * Output filename. The file is an HTML table that Excel opens as a legacy `.xls` workbook, so a + * `.xlsx` extension is replaced with `.xls` (Excel refuses HTML content named `.xlsx`). Use + * `exportToExcelAdvanced` for a real `.xlsx`. Default: `'export.xls'` + */ + fileName?: string; + /** Sheet tab name. Characters Excel rejects are replaced and the name is cut to 31 characters. Default: `'Sheet1'` */ + sheetName?: string; + includeHeaders?: boolean; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the totals rows are recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; + /** + * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so + * Excel does not run them as formulas. Default: `true` + * @since v3.0 + */ + escapeFormulas?: boolean; +} + +/** Excel's text number format: the cell keeps its text as typed (leading zeros, long ids, "1-2"). */ +const XLS_TEXT_CLASS = 'ogx-xls-text'; + +/** + * Whether an HTML-Excel data cell should be marked as text. Number, date and boolean columns, and + * values that are numbers, dates or booleans, are left for Excel to type. + */ +function isXlsTextCell(col: GridColDef, raw: unknown): boolean { + return col.type !== 'number' && col.type !== 'date' && col.type !== 'boolean' && !isNonTextValue(raw); +} + +function xlsFileName(fileName: string): string { + if (!/\.xlsx$/i.test(fileName)) return fileName; + const corrected = fileName.replace(/\.xlsx$/i, '.xls'); + console.warn( + `[exportToExcel] "${fileName}" renamed to "${corrected}": exportToExcel writes an HTML table that Excel ` + + 'refuses to open as .xlsx. Use exportToExcelAdvanced for a real .xlsx file.' + ); + return corrected; +} + +export function exportToExcel(rows: R[], columns: GridColDef[], options?: ExcelExportOptions): void; +export function exportToExcel(rows: R[], columns: GridColDef[], options?: ExcelExportOptions): void; +export function exportToExcel( + rows: R[], + columns: GridColDef[], + options: ExcelExportOptions = {} +): void { + const { + fileName = 'export.xls', + sheetName = 'Sheet1', + includeHeaders = true, + selectedRows, + groupedRows, + escapeFormulas = true, + } = options; + + const exportColumns = getExportColumns(columns); + const aggModel = options.aggregationModel || {}; + const cellText = (text: string, fromNonText = false): string => + escapeHTML(escapeFormulas ? neutralizeFormula(text, fromNonText) : text); + const textTd = (html: string): string => `${html}`; + + const header = '' + + '' + + ``; + + let html = header; + + if (includeHeaders) { + html += ''; + exportColumns.forEach(col => { html += ``; }); + html += ''; + } + + html += ''; + + const dataCell = (cell: ExportCell, col: GridColDef, prefix = ''): string => { + const content = prefix + cellText(cell.text, cell.fromNonText); + return isXlsTextCell(col, cell.raw) ? textTd(content) : ``; + }; + + if (shouldExportGrouped(groupedRows, selectedRows)) { + groupedRows.forEach(entry => { + const indent = ' '.repeat(entry.depth * 2); + if (entry.type === 'group-header') { + html += ``; + html += ``; + html += ''; + } else if (entry.type === 'leaf' && entry.row) { + const row = entry.row as R; + html += ''; + exportColumns.forEach((col, i) => { html += dataCell(exportCell(row, col), col, i === 0 ? escapeHTML(indent) : ''); }); + html += ''; + } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) { + const values = entry.aggregatedValues; + const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`; + const texts = aggregateTexts(exportColumns, values, aggModel); + html += ``; + exportColumns.forEach((col, i) => { + if (i === 0) { html += textTd(escapeHTML(summaryLabelText(label, texts[0]))); return; } + html += texts[i] ? `` : ''; + }); + html += ''; + } + }); + } else { + const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId); + + if (rowsToExport.length === 0) { + console.warn('No rows to export'); + return; + } + + rowsToExport.forEach(row => { + html += ''; + exportColumns.forEach(col => { html += dataCell(exportCell(row, col), col); }); + html += ''; + }); + + const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel); + if (aggResult) { + html += ''; + exportColumns.forEach(col => { + html += aggModel[col.field] + ? `` + : ''; + }); + html += ''; + const texts = aggregateTexts(exportColumns, aggResult, aggModel); + exportColumns.forEach((col, i) => { + html += texts[i] ? `` : ''; + }); + html += ''; + } + } + + html += '
${cellText(col.headerName || col.field)}
${content}
${escapeHTML(indent)}${cellText(groupHeaderLabel(entry, columns))}
${cellText(texts[i], isNonTextValue(values[col.field]))}
${escapeHTML(aggModel[col.field].toUpperCase())}
${cellText(texts[i], isNonTextValue(aggResult[col.field]))}
'; + downloadFile(html, xlsFileName(fileName), 'application/vnd.ms-excel'); +} + +// ============================================================================ +// JSON Export +// ============================================================================ diff --git a/lib/utils/export/exportJson.ts b/lib/utils/export/exportJson.ts new file mode 100644 index 00000000..57e349a0 --- /dev/null +++ b/lib/utils/export/exportJson.ts @@ -0,0 +1,179 @@ + +import type { + GridColDef, + GridRowId, + GridRowModel, + GridValidRowModel, + GridAggregationModel, + GridGroupedExportRow, +} from '../../types'; +import { groupHeaderLabel } from './groupLabel'; +import { + aggregationForExport, + getExportColumns, + getRawExportValue, + hasSelection, + pickExportedAggregates, + rowsForExport, + shouldExportGrouped, +} from './exportShared'; + +export interface CsvExportOptions { + fileName?: string; + includeHeaders?: boolean; + delimiter?: string; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the totals row is recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; + /** + * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so + * spreadsheet apps show them as text instead of running them as formulas (CSV injection). + * Plain signed numbers are left alone. Default: `true` + * @since v3.0 + */ + escapeFormulas?: boolean; + /** + * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文) + * correctly. Default: `true` + * @since v3.0 + */ + bom?: boolean; +} +import { downloadFile } from './exportRows'; + +export interface JsonExportOptions { + fileName?: string; + pretty?: boolean; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the aggregation values are recomputed over them. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits a nested grouping tree instead of a flat array. */ + groupedRows?: GridGroupedExportRow[]; +} + +/** + * Export data to JSON format. Values are written raw (after `valueGetter`, without + * `valueFormatter`), including aggregation values, so the output can be parsed as data. + */ +export function exportToJson(rows: R[], columns: GridColDef[], options?: JsonExportOptions): void; +export function exportToJson(rows: R[], columns: GridColDef[], options?: JsonExportOptions): void; +export function exportToJson( + rows: R[], + columns: GridColDef[], + options: JsonExportOptions = {} +): void { + const { + fileName = 'export.json', + pretty = true, + selectedRows, + groupedRows, + } = options; + + const exportColumns = getExportColumns(columns); + + type GroupNode = { + group: string; + field: string; + value: unknown; + rows: Record[]; + subtotals?: Record; + children?: GroupNode[]; + }; + + type JsonPayload = + | Record[] + | { data: Record[]; aggregation: { labels: Record; values: Record } } + | { groups: GroupNode[]; grandTotal?: Record }; + + const rowObject = (row: R): Record => { + const obj: Record = {}; + exportColumns.forEach(col => { obj[col.field] = getRawExportValue(row, col); }); + return obj; + }; + + let outData: JsonPayload; + + if (shouldExportGrouped(groupedRows, selectedRows)) { + // Build nested tree from the flat ordered list + const stack: GroupNode[] = []; + const roots: GroupNode[] = []; + let grandTotal: Record | undefined; + + groupedRows.forEach(entry => { + if (entry.type === 'group-header') { + const node: GroupNode = { + group: groupHeaderLabel(entry, columns), + field: entry.groupField ?? '', + value: entry.groupValue, + rows: [], + children: [], + }; + while (stack.length > entry.depth) stack.pop(); + if (stack.length === 0) { + roots.push(node); + } else { + (stack[stack.length - 1].children ??= []).push(node); + } + stack.push(node); + } else if (entry.type === 'leaf' && entry.row) { + if (stack.length > 0) stack[stack.length - 1].rows.push(rowObject(entry.row as R)); + } else if (entry.type === 'group-subtotal' && entry.aggregatedValues && stack.length > 0) { + stack[stack.length - 1].subtotals = pickExportedAggregates(entry.aggregatedValues, exportColumns); + stack.pop(); + } else if (entry.type === 'grand-total' && entry.aggregatedValues) { + grandTotal = pickExportedAggregates(entry.aggregatedValues, exportColumns); + } + }); + + outData = grandTotal ? { groups: roots, grandTotal } : { groups: roots }; + } else { + const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId); + + if (rowsToExport.length === 0) { + console.warn('No rows to export'); + return; + } + + const data = rowsToExport.map(rowObject); + + const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel); + if (aggResult) { + const aggModel = options.aggregationModel || {}; + const summaryLabelObj: Record = {}; + exportColumns.forEach(col => { if (aggModel[col.field]) summaryLabelObj[col.field] = aggModel[col.field].toUpperCase(); }); + const summaryDataObj: Record = {}; + exportColumns.forEach(col => { + if (aggResult[col.field] !== undefined && aggResult[col.field] !== null) summaryDataObj[col.field] = aggResult[col.field]; + }); + outData = { data, aggregation: { labels: summaryLabelObj, values: summaryDataObj } }; + } else { + outData = data; + } + } + + downloadFile(pretty ? JSON.stringify(outData, null, 2) : JSON.stringify(outData), fileName, 'application/json;charset=utf-8;'); +} + +// ============================================================================ +// Print +// ============================================================================ diff --git a/lib/utils/export/exportRows.ts b/lib/utils/export/exportRows.ts new file mode 100644 index 00000000..fa2222dc --- /dev/null +++ b/lib/utils/export/exportRows.ts @@ -0,0 +1,85 @@ +// Shared by the CSV, HTML-Excel, JSON and print exporters: one data cell's text, the aggregate texts +// of a total row, and the browser download. + +import type { + GridColDef, + GridRowId, + GridRowModel, + GridValidRowModel, + GridAggregationModel, + GridGroupedExportRow, +} from '../../types'; +import { + formatExportAggregate, + formatExportValue, + getRawExportValue, + isTrustedNonText, +} from './exportShared'; + +export interface CsvExportOptions { + fileName?: string; + includeHeaders?: boolean; + delimiter?: string; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the totals row is recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; + /** + * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so + * spreadsheet apps show them as text instead of running them as formulas (CSV injection). + * Plain signed numbers are left alone. Default: `true` + * @since v3.0 + */ + escapeFormulas?: boolean; + /** + * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文) + * correctly. Default: `true` + * @since v3.0 + */ + bom?: boolean; +} + +/** One exported cell of a data row: its text, and whether that text was formatted from a non-text value. */ +export interface ExportCell { + text: string; + raw: unknown; + /** The text came from the grid's own formatting of a number, date or boolean (see `isTrustedNonText`). */ + fromNonText: boolean; +} + +export function exportCell(row: R, col: GridColDef): ExportCell { + const raw = getRawExportValue(row, col); + const text = formatExportValue(row, col, raw); + return { text, raw, fromNonText: isTrustedNonText(raw, text, !!col.valueFormatter) }; +} + +/** Aggregate text per export column for one subtotal / total record. */ +export function aggregateTexts( + exportColumns: GridColDef[], + aggregates: Record, + aggModel: GridAggregationModel, +): string[] { + return exportColumns.map(col => formatExportAggregate(col, aggModel[col.field], aggregates[col.field], aggregates)); +} + +export function downloadFile(content: string, fileName: string, mimeType: string): void { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/lib/utils/export/index.ts b/lib/utils/export/index.ts index a0a28de6..fca14eea 100644 --- a/lib/utils/export/index.ts +++ b/lib/utils/export/index.ts @@ -1,697 +1,7 @@ - -import type { GridColDef, GridRowId, GridRowModel, GridValidRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types'; -import { groupHeaderLabel } from './groupLabel'; -import { - aggregationForExport, - escapeHTML, - formatExportAggregate, - formatExportValue, - getExportColumns, - getRawExportValue, - hasSelection, - isNonTextValue, - isTrustedNonText, - neutralizeFormula, - pickExportedAggregates, - rowsForExport, - sanitizeSheetName, - shouldExportGrouped, - summaryLabelText, -} from './exportShared'; - -export interface CsvExportOptions { - fileName?: string; - includeHeaders?: boolean; - delimiter?: string; - /** - * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the - * selected rows are exported flat), and the totals row is recomputed over the selected rows. - */ - selectedRows?: (string | number)[]; - /** - * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not - * copy them onto `row.id` (v3.0+). Defaults to `row.id`. - */ - getRowId?: (row: R) => GridRowId; - aggregationResult?: Record | null; - aggregationModel?: GridAggregationModel | null; - /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ - groupedRows?: GridGroupedExportRow[]; - /** - * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so - * spreadsheet apps show them as text instead of running them as formulas (CSV injection). - * Plain signed numbers are left alone. Default: `true` - * @since v3.0 - */ - escapeFormulas?: boolean; - /** - * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文) - * correctly. Default: `true` - * @since v3.0 - */ - bom?: boolean; -} - -/** One exported cell of a data row: its text, and whether that text was formatted from a non-text value. */ -interface ExportCell { - text: string; - raw: unknown; - /** The text came from the grid's own formatting of a number, date or boolean (see `isTrustedNonText`). */ - fromNonText: boolean; -} - -function exportCell(row: R, col: GridColDef): ExportCell { - const raw = getRawExportValue(row, col); - const text = formatExportValue(row, col, raw); - return { text, raw, fromNonText: isTrustedNonText(raw, text, !!col.valueFormatter) }; -} - -/** Aggregate text per export column for one subtotal / total record. */ -function aggregateTexts( - exportColumns: GridColDef[], - aggregates: Record, - aggModel: GridAggregationModel, -): string[] { - return exportColumns.map(col => formatExportAggregate(col, aggModel[col.field], aggregates[col.field], aggregates)); -} - -const UTF8_BOM = String.fromCharCode(0xfeff); - -export function exportToCsv(rows: R[], columns: GridColDef[], options?: CsvExportOptions): void; -export function exportToCsv(rows: R[], columns: GridColDef[], options?: CsvExportOptions): void; -export function exportToCsv( - rows: R[], - columns: GridColDef[], - options: CsvExportOptions = {} -): void { - const { - fileName = 'export.csv', - includeHeaders = true, - delimiter = ',', - selectedRows, - groupedRows, - escapeFormulas = true, - bom = true, - } = options; - - const exportColumns = getExportColumns(columns); - const aggModel = options.aggregationModel || {}; - const field = (text: string, fromNonText = false): string => - escapeCSV(escapeFormulas ? neutralizeFormula(text, fromNonText) : text, delimiter); - const headerLine = () => exportColumns.map(col => field(col.headerName || col.field)).join(delimiter) + '\n'; - - let csvContent = ''; - - if (shouldExportGrouped(groupedRows, selectedRows)) { - // Grouped export path - if (includeHeaders) csvContent += headerLine(); - groupedRows.forEach(entry => { - const indent = ' '.repeat(entry.depth); - if (entry.type === 'group-header') { - const labelText = groupHeaderLabel(entry, columns); - const label = escapeCSV(indent + (escapeFormulas ? neutralizeFormula(labelText) : labelText), delimiter); - csvContent += exportColumns.map((_, i) => (i === 0 ? label : '')).join(delimiter) + '\n'; - } else if (entry.type === 'leaf' && entry.row) { - const row = entry.row as R; - const cells = exportColumns.map((col, i) => { - const cell = exportCell(row, col); - const text = escapeFormulas ? neutralizeFormula(cell.text, cell.fromNonText) : cell.text; - return escapeCSV((i === 0 ? indent : '') + text, delimiter); - }); - csvContent += cells.join(delimiter) + '\n'; - } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) { - const values = entry.aggregatedValues; - const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`; - const texts = aggregateTexts(exportColumns, values, aggModel); - const cells = exportColumns.map((col, i) => (i === 0 - ? escapeCSV(summaryLabelText(label, texts[0]), delimiter) - : field(texts[i], isNonTextValue(values[col.field])))); - csvContent += cells.join(delimiter) + '\n'; - } - }); - } else { - const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId); - - if (rowsToExport.length === 0) { - console.warn('No rows to export'); - return; - } - - if (includeHeaders) csvContent += headerLine(); - - rowsToExport.forEach(row => { - const values = exportColumns.map(col => { - const cell = exportCell(row, col); - return field(cell.text, cell.fromNonText); - }); - csvContent += values.join(delimiter) + '\n'; - }); - - const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel); - if (aggResult) { - const aggLabels = exportColumns.map(col => field(aggModel[col.field] ? aggModel[col.field].toUpperCase() : '')); - csvContent += aggLabels.join(delimiter) + '\n'; - const texts = aggregateTexts(exportColumns, aggResult, aggModel); - csvContent += exportColumns.map((col, i) => field(texts[i], isNonTextValue(aggResult[col.field]))).join(delimiter) + '\n'; - } - } - - downloadFile((bom ? UTF8_BOM : '') + csvContent, fileName, 'text/csv;charset=utf-8;'); -} - -/** Quote a CSV field when it contains the delimiter, a quote, or a line break (LF or a bare CR). */ -function escapeCSV(value: string, delimiter: string): string { - if ((delimiter !== '' && value.includes(delimiter)) || value.includes('"') || value.includes('\n') || value.includes('\r')) { - return `"${value.replace(/"/g, '""')}"`; - } - return value; -} - -export interface ExcelExportOptions { - /** - * Output filename. The file is an HTML table that Excel opens as a legacy `.xls` workbook, so a - * `.xlsx` extension is replaced with `.xls` (Excel refuses HTML content named `.xlsx`). Use - * `exportToExcelAdvanced` for a real `.xlsx`. Default: `'export.xls'` - */ - fileName?: string; - /** Sheet tab name. Characters Excel rejects are replaced and the name is cut to 31 characters. Default: `'Sheet1'` */ - sheetName?: string; - includeHeaders?: boolean; - /** - * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the - * selected rows are exported flat), and the totals rows are recomputed over the selected rows. - */ - selectedRows?: (string | number)[]; - /** - * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not - * copy them onto `row.id` (v3.0+). Defaults to `row.id`. - */ - getRowId?: (row: R) => GridRowId; - aggregationResult?: Record | null; - aggregationModel?: GridAggregationModel | null; - /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ - groupedRows?: GridGroupedExportRow[]; - /** - * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so - * Excel does not run them as formulas. Default: `true` - * @since v3.0 - */ - escapeFormulas?: boolean; -} - -/** Excel's text number format: the cell keeps its text as typed (leading zeros, long ids, "1-2"). */ -const XLS_TEXT_CLASS = 'ogx-xls-text'; - -/** - * Whether an HTML-Excel data cell should be marked as text. Number, date and boolean columns, and - * values that are numbers, dates or booleans, are left for Excel to type. - */ -function isXlsTextCell(col: GridColDef, raw: unknown): boolean { - return col.type !== 'number' && col.type !== 'date' && col.type !== 'boolean' && !isNonTextValue(raw); -} - -function xlsFileName(fileName: string): string { - if (!/\.xlsx$/i.test(fileName)) return fileName; - const corrected = fileName.replace(/\.xlsx$/i, '.xls'); - console.warn( - `[exportToExcel] "${fileName}" renamed to "${corrected}": exportToExcel writes an HTML table that Excel ` + - 'refuses to open as .xlsx. Use exportToExcelAdvanced for a real .xlsx file.' - ); - return corrected; -} - -export function exportToExcel(rows: R[], columns: GridColDef[], options?: ExcelExportOptions): void; -export function exportToExcel(rows: R[], columns: GridColDef[], options?: ExcelExportOptions): void; -export function exportToExcel( - rows: R[], - columns: GridColDef[], - options: ExcelExportOptions = {} -): void { - const { - fileName = 'export.xls', - sheetName = 'Sheet1', - includeHeaders = true, - selectedRows, - groupedRows, - escapeFormulas = true, - } = options; - - const exportColumns = getExportColumns(columns); - const aggModel = options.aggregationModel || {}; - const cellText = (text: string, fromNonText = false): string => - escapeHTML(escapeFormulas ? neutralizeFormula(text, fromNonText) : text); - const textTd = (html: string): string => `${html}`; - - const header = '' - + '' - + ``; - - let html = header; - - if (includeHeaders) { - html += ''; - exportColumns.forEach(col => { html += ``; }); - html += ''; - } - - html += ''; - - const dataCell = (cell: ExportCell, col: GridColDef, prefix = ''): string => { - const content = prefix + cellText(cell.text, cell.fromNonText); - return isXlsTextCell(col, cell.raw) ? textTd(content) : ``; - }; - - if (shouldExportGrouped(groupedRows, selectedRows)) { - groupedRows.forEach(entry => { - const indent = ' '.repeat(entry.depth * 2); - if (entry.type === 'group-header') { - html += ``; - html += ``; - html += ''; - } else if (entry.type === 'leaf' && entry.row) { - const row = entry.row as R; - html += ''; - exportColumns.forEach((col, i) => { html += dataCell(exportCell(row, col), col, i === 0 ? escapeHTML(indent) : ''); }); - html += ''; - } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) { - const values = entry.aggregatedValues; - const label = entry.type === 'grand-total' ? 'Grand Total' : `${indent}Subtotal`; - const texts = aggregateTexts(exportColumns, values, aggModel); - html += ``; - exportColumns.forEach((col, i) => { - if (i === 0) { html += textTd(escapeHTML(summaryLabelText(label, texts[0]))); return; } - html += texts[i] ? `` : ''; - }); - html += ''; - } - }); - } else { - const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId); - - if (rowsToExport.length === 0) { - console.warn('No rows to export'); - return; - } - - rowsToExport.forEach(row => { - html += ''; - exportColumns.forEach(col => { html += dataCell(exportCell(row, col), col); }); - html += ''; - }); - - const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel); - if (aggResult) { - html += ''; - exportColumns.forEach(col => { - html += aggModel[col.field] - ? `` - : ''; - }); - html += ''; - const texts = aggregateTexts(exportColumns, aggResult, aggModel); - exportColumns.forEach((col, i) => { - html += texts[i] ? `` : ''; - }); - html += ''; - } - } - - html += '
${cellText(col.headerName || col.field)}
${content}
${escapeHTML(indent)}${cellText(groupHeaderLabel(entry, columns))}
${cellText(texts[i], isNonTextValue(values[col.field]))}
${escapeHTML(aggModel[col.field].toUpperCase())}
${cellText(texts[i], isNonTextValue(aggResult[col.field]))}
'; - downloadFile(html, xlsFileName(fileName), 'application/vnd.ms-excel'); -} - -// ============================================================================ -// JSON Export -// ============================================================================ - -export interface JsonExportOptions { - fileName?: string; - pretty?: boolean; - /** - * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the - * selected rows are exported flat), and the aggregation values are recomputed over them. - */ - selectedRows?: (string | number)[]; - /** - * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not - * copy them onto `row.id` (v3.0+). Defaults to `row.id`. - */ - getRowId?: (row: R) => GridRowId; - aggregationResult?: Record | null; - aggregationModel?: GridAggregationModel | null; - /** When provided, emits a nested grouping tree instead of a flat array. */ - groupedRows?: GridGroupedExportRow[]; -} - -/** - * Export data to JSON format. Values are written raw (after `valueGetter`, without - * `valueFormatter`), including aggregation values, so the output can be parsed as data. - */ -export function exportToJson(rows: R[], columns: GridColDef[], options?: JsonExportOptions): void; -export function exportToJson(rows: R[], columns: GridColDef[], options?: JsonExportOptions): void; -export function exportToJson( - rows: R[], - columns: GridColDef[], - options: JsonExportOptions = {} -): void { - const { - fileName = 'export.json', - pretty = true, - selectedRows, - groupedRows, - } = options; - - const exportColumns = getExportColumns(columns); - - type GroupNode = { - group: string; - field: string; - value: unknown; - rows: Record[]; - subtotals?: Record; - children?: GroupNode[]; - }; - - type JsonPayload = - | Record[] - | { data: Record[]; aggregation: { labels: Record; values: Record } } - | { groups: GroupNode[]; grandTotal?: Record }; - - const rowObject = (row: R): Record => { - const obj: Record = {}; - exportColumns.forEach(col => { obj[col.field] = getRawExportValue(row, col); }); - return obj; - }; - - let outData: JsonPayload; - - if (shouldExportGrouped(groupedRows, selectedRows)) { - // Build nested tree from the flat ordered list - const stack: GroupNode[] = []; - const roots: GroupNode[] = []; - let grandTotal: Record | undefined; - - groupedRows.forEach(entry => { - if (entry.type === 'group-header') { - const node: GroupNode = { - group: groupHeaderLabel(entry, columns), - field: entry.groupField ?? '', - value: entry.groupValue, - rows: [], - children: [], - }; - while (stack.length > entry.depth) stack.pop(); - if (stack.length === 0) { - roots.push(node); - } else { - (stack[stack.length - 1].children ??= []).push(node); - } - stack.push(node); - } else if (entry.type === 'leaf' && entry.row) { - if (stack.length > 0) stack[stack.length - 1].rows.push(rowObject(entry.row as R)); - } else if (entry.type === 'group-subtotal' && entry.aggregatedValues && stack.length > 0) { - stack[stack.length - 1].subtotals = pickExportedAggregates(entry.aggregatedValues, exportColumns); - stack.pop(); - } else if (entry.type === 'grand-total' && entry.aggregatedValues) { - grandTotal = pickExportedAggregates(entry.aggregatedValues, exportColumns); - } - }); - - outData = grandTotal ? { groups: roots, grandTotal } : { groups: roots }; - } else { - const rowsToExport = rowsForExport(rows, selectedRows, options.getRowId); - - if (rowsToExport.length === 0) { - console.warn('No rows to export'); - return; - } - - const data = rowsToExport.map(rowObject); - - const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, options.aggregationResult, options.aggregationModel); - if (aggResult) { - const aggModel = options.aggregationModel || {}; - const summaryLabelObj: Record = {}; - exportColumns.forEach(col => { if (aggModel[col.field]) summaryLabelObj[col.field] = aggModel[col.field].toUpperCase(); }); - const summaryDataObj: Record = {}; - exportColumns.forEach(col => { - if (aggResult[col.field] !== undefined && aggResult[col.field] !== null) summaryDataObj[col.field] = aggResult[col.field]; - }); - outData = { data, aggregation: { labels: summaryLabelObj, values: summaryDataObj } }; - } else { - outData = data; - } - } - - downloadFile(pretty ? JSON.stringify(outData, null, 2) : JSON.stringify(outData), fileName, 'application/json;charset=utf-8;'); -} - -// ============================================================================ -// Print -// ============================================================================ - -export interface PrintOptions { - title?: string; - /** - * Print only these row IDs. A non-empty selection takes precedence over `groupedRows` (the - * selected rows are printed flat), and the totals are recomputed over the selected rows. - */ - selectedRows?: (string | number)[]; - /** - * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not - * copy them onto `row.id` (v3.0+). Defaults to `row.id`. - */ - getRowId?: (row: R) => GridRowId; - aggregationResult?: Record | null; - aggregationModel?: GridAggregationModel | null; - /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ - groupedRows?: GridGroupedExportRow[]; -} - -const SAFE_IMAGE_URL = /^(?:https?:|data:image\/|blob:)/i; -const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i; - -/** - * The URL to use as an `` in the print window, or null when it is not an image URL: - * http(s), `data:image/…`, `blob:` and relative URLs are allowed; `javascript:`, `data:text/html`, - * `file:` and other schemes are not. - */ -function printableImageUrl(value: string): string | null { - const url = value.trim(); - if (!url) return null; - if (SAFE_IMAGE_URL.test(url)) return url; - return URL_SCHEME.test(url) ? null : url; -} - -/** - * Open print dialog with formatted table - */ -export function printGrid(rows: R[], columns: GridColDef[], titleOrOptions?: string | PrintOptions): Promise; -export function printGrid(rows: R[], columns: GridColDef[], titleOrOptions?: string | PrintOptions): Promise; -export async function printGrid( - rows: R[], - columns: GridColDef[], - titleOrOptions?: string | PrintOptions -): Promise { - // 1. Open window immediately to resolve user activation constraints and provide feedback - const printWindow = window.open('', '_blank'); - - if (!printWindow) { - console.error('Failed to open print window. Please check your popup blocker settings.'); - return; - } - - // 2. Show loading state - printWindow.document.write(` - - - - Preparing Print... - - - -
-
-

Generating Print Preview...

-

Processing data, please wait.

-
- - - `); - printWindow.document.close(); - - // 3. Yield to main thread to allow the new window to render its loading state - await new Promise(resolve => setTimeout(resolve, 100)); - - try { - let title = 'Print'; - let selectedRows: (string | number)[] | undefined; - let getRowId: ((row: R) => GridRowId) | undefined; - let aggregationResult: Record | null = null; - let aggregationModel: GridAggregationModel | null = null; - - let groupedRows: GridGroupedExportRow[] | undefined; - - if (typeof titleOrOptions === 'string') { - title = titleOrOptions; - } else if (typeof titleOrOptions === 'object') { - title = titleOrOptions.title || 'Print'; - selectedRows = titleOrOptions.selectedRows; - getRowId = titleOrOptions.getRowId; - aggregationResult = titleOrOptions.aggregationResult || null; - aggregationModel = titleOrOptions.aggregationModel || null; - groupedRows = titleOrOptions.groupedRows; - } - - const exportColumns = getExportColumns(columns); - const aggModel = aggregationModel || {}; - - // Group-row rules are qualified with `tr` so they outrank the stripe rule, which has the same - // specificity, by coming later. - let html = `${escapeHTML(title || 'Print')}`; - - if (title) html += `

${escapeHTML(title)}

`; - html += ''; - exportColumns.forEach(col => { html += ``; }); - html += ''; - - const dataCell = (row: R, col: GridColDef, prefixHtml = '', centerImage = false): string => { - const { text } = exportCell(row, col); - const src = col.type === 'image' ? printableImageUrl(text) : null; - if (src !== null) { - const img = `${escapeHTML(col.headerName || col.field)}`; - return centerImage ? `` : ``; - } - return ``; - }; - - if (shouldExportGrouped(groupedRows, selectedRows)) { - groupedRows.forEach(entry => { - const indent = ' '.repeat(entry.depth * 4); - if (entry.type === 'group-header') { - html += ``; - } else if (entry.type === 'leaf' && entry.row) { - const row = entry.row as R; - html += ''; - exportColumns.forEach((col, i) => { html += dataCell(row, col, i === 0 ? indent : ''); }); - html += ''; - } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) { - const isGrand = entry.type === 'grand-total'; - const texts = aggregateTexts(exportColumns, entry.aggregatedValues, aggModel); - html += ``; - exportColumns.forEach((_, i) => { - if (i === 0) { - html += ``; - return; - } - html += texts[i] ? `` : ''; - }); - html += ''; - } - }); - } else { - const rowsToExport = rowsForExport(rows, selectedRows, getRowId); - - if (rowsToExport.length === 0) { - printWindow.document.body.innerHTML = '

No rows to export

'; - return; - } - - rowsToExport.forEach(row => { - html += ''; - exportColumns.forEach(col => { html += dataCell(row, col, '', true); }); - html += ''; - }); - - const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, aggregationResult, aggregationModel); - if (aggResult) { - html += ''; - exportColumns.forEach(col => { - html += aggModel[col.field] - ? `` - : ''; - }); - html += ''; - aggregateTexts(exportColumns, aggResult, aggModel).forEach(text => { - html += text ? `` : ''; - }); - html += ''; - } - } - - html += '
${escapeHTML(col.headerName || col.field)}
${img}
${img}${prefixHtml}${escapeHTML(text)}
${indent}${escapeHTML(groupHeaderLabel(entry, columns))}
${isGrand ? '' : indent}${escapeHTML(summaryLabelText(isGrand ? 'Grand Total' : 'Subtotal', texts[0]))}${escapeHTML(texts[i])}
${escapeHTML(aggModel[col.field].toUpperCase())}
${escapeHTML(text)}
'; - - // Write the HTML content - printWindow.document.open(); - printWindow.document.write(html); - printWindow.document.close(); - - // Set the document title explicitly (fixes about:blank issue) - printWindow.document.title = title || 'Print Preview'; - - printWindow.focus(); - - let hasPrinted = false; - - printWindow.onload = () => { - if (!hasPrinted) { - hasPrinted = true; - printWindow.print(); - - setTimeout(() => { - printWindow.close(); - }, 100); - } - }; - - setTimeout(() => { - if (!hasPrinted && printWindow && !printWindow.closed) { - hasPrinted = true; - printWindow.print(); - setTimeout(() => { - if (!printWindow.closed) printWindow.close(); - }, 100); - } - }, 500); - - } catch (e) { - console.error('Print generation failed:', e); - if (printWindow && !printWindow.closed) { - const message = e instanceof Error ? e.message : String(e); - printWindow.document.body.innerHTML = `

Error Generating Print Preview

${escapeHTML(message)}

`; - } - } -} - -function downloadFile(content: string, fileName: string, mimeType: string): void { - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); -} - +// The exporters, one file per format; this module only re-exports them (public import path). +export { exportToCsv, type CsvExportOptions } from './exportCsv'; +export { exportToExcel, type ExcelExportOptions } from './exportExcelHtml'; +export { exportToJson, type JsonExportOptions } from './exportJson'; +export { printGrid, type PrintOptions } from './printGrid'; export { exportToPdf } from './exportToPdf'; export type { PdfExportOptions, PdfExportProgress } from '../../types'; diff --git a/lib/utils/export/printGrid.ts b/lib/utils/export/printGrid.ts new file mode 100644 index 00000000..937d587b --- /dev/null +++ b/lib/utils/export/printGrid.ts @@ -0,0 +1,282 @@ + +import type { + GridColDef, + GridRowId, + GridRowModel, + GridValidRowModel, + GridAggregationModel, + GridGroupedExportRow, +} from '../../types'; +import { groupHeaderLabel } from './groupLabel'; +import { + aggregationForExport, + escapeHTML, + getExportColumns, + hasSelection, + rowsForExport, + shouldExportGrouped, + summaryLabelText, +} from './exportShared'; + +export interface CsvExportOptions { + fileName?: string; + includeHeaders?: boolean; + delimiter?: string; + /** + * Export only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are exported flat), and the totals row is recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; + /** + * Prefix text cells that start with `=`, `+`, `-`, `@`, tab or carriage return with `'`, so + * spreadsheet apps show them as text instead of running them as formulas (CSV injection). + * Plain signed numbers are left alone. Default: `true` + * @since v3.0 + */ + escapeFormulas?: boolean; + /** + * Start the file with a UTF-8 byte-order mark, so Excel reads non-ASCII text (é, ü, ₹, 中文) + * correctly. Default: `true` + * @since v3.0 + */ + bom?: boolean; +} +import { aggregateTexts, exportCell } from './exportRows'; + +export interface PrintOptions { + title?: string; + /** + * Print only these row IDs. A non-empty selection takes precedence over `groupedRows` (the + * selected rows are printed flat), and the totals are recomputed over the selected rows. + */ + selectedRows?: (string | number)[]; + /** + * The grid's `getRowId`, when it has one: `selectedRows` holds those ids, and the grid does not + * copy them onto `row.id` (v3.0+). Defaults to `row.id`. + */ + getRowId?: (row: R) => GridRowId; + aggregationResult?: Record | null; + aggregationModel?: GridAggregationModel | null; + /** When provided, emits group headers, leaf rows, subtotals, and a grand total instead of a flat row list. */ + groupedRows?: GridGroupedExportRow[]; +} + +const SAFE_IMAGE_URL = /^(?:https?:|data:image\/|blob:)/i; +const URL_SCHEME = /^[a-z][a-z\d+.-]*:/i; + +/** + * The URL to use as an `` in the print window, or null when it is not an image URL: + * http(s), `data:image/…`, `blob:` and relative URLs are allowed; `javascript:`, `data:text/html`, + * `file:` and other schemes are not. + */ +function printableImageUrl(value: string): string | null { + const url = value.trim(); + if (!url) return null; + if (SAFE_IMAGE_URL.test(url)) return url; + return URL_SCHEME.test(url) ? null : url; +} + +/** + * Open print dialog with formatted table + */ +export function printGrid(rows: R[], columns: GridColDef[], titleOrOptions?: string | PrintOptions): Promise; +export function printGrid(rows: R[], columns: GridColDef[], titleOrOptions?: string | PrintOptions): Promise; +export async function printGrid( + rows: R[], + columns: GridColDef[], + titleOrOptions?: string | PrintOptions +): Promise { + // 1. Open window immediately to resolve user activation constraints and provide feedback + const printWindow = window.open('', '_blank'); + + if (!printWindow) { + console.error('Failed to open print window. Please check your popup blocker settings.'); + return; + } + + // 2. Show loading state + printWindow.document.write(` + + + + Preparing Print... + + + +
+
+

Generating Print Preview...

+

Processing data, please wait.

+
+ + + `); + printWindow.document.close(); + + // 3. Yield to main thread to allow the new window to render its loading state + await new Promise(resolve => setTimeout(resolve, 100)); + + try { + let title = 'Print'; + let selectedRows: (string | number)[] | undefined; + let getRowId: ((row: R) => GridRowId) | undefined; + let aggregationResult: Record | null = null; + let aggregationModel: GridAggregationModel | null = null; + + let groupedRows: GridGroupedExportRow[] | undefined; + + if (typeof titleOrOptions === 'string') { + title = titleOrOptions; + } else if (typeof titleOrOptions === 'object') { + title = titleOrOptions.title || 'Print'; + selectedRows = titleOrOptions.selectedRows; + getRowId = titleOrOptions.getRowId; + aggregationResult = titleOrOptions.aggregationResult || null; + aggregationModel = titleOrOptions.aggregationModel || null; + groupedRows = titleOrOptions.groupedRows; + } + + const exportColumns = getExportColumns(columns); + const aggModel = aggregationModel || {}; + + // Group-row rules are qualified with `tr` so they outrank the stripe rule, which has the same + // specificity, by coming later. + let html = `${escapeHTML(title || 'Print')}`; + + if (title) html += `

${escapeHTML(title)}

`; + html += ''; + exportColumns.forEach(col => { html += ``; }); + html += ''; + + const dataCell = (row: R, col: GridColDef, prefixHtml = '', centerImage = false): string => { + const { text } = exportCell(row, col); + const src = col.type === 'image' ? printableImageUrl(text) : null; + if (src !== null) { + const img = `${escapeHTML(col.headerName || col.field)}`; + return centerImage ? `` : ``; + } + return ``; + }; + + if (shouldExportGrouped(groupedRows, selectedRows)) { + groupedRows.forEach(entry => { + const indent = ' '.repeat(entry.depth * 4); + if (entry.type === 'group-header') { + html += ``; + } else if (entry.type === 'leaf' && entry.row) { + const row = entry.row as R; + html += ''; + exportColumns.forEach((col, i) => { html += dataCell(row, col, i === 0 ? indent : ''); }); + html += ''; + } else if ((entry.type === 'group-subtotal' || entry.type === 'grand-total') && entry.aggregatedValues) { + const isGrand = entry.type === 'grand-total'; + const texts = aggregateTexts(exportColumns, entry.aggregatedValues, aggModel); + html += ``; + exportColumns.forEach((_, i) => { + if (i === 0) { + html += ``; + return; + } + html += texts[i] ? `` : ''; + }); + html += ''; + } + }); + } else { + const rowsToExport = rowsForExport(rows, selectedRows, getRowId); + + if (rowsToExport.length === 0) { + printWindow.document.body.innerHTML = '

No rows to export

'; + return; + } + + rowsToExport.forEach(row => { + html += ''; + exportColumns.forEach(col => { html += dataCell(row, col, '', true); }); + html += ''; + }); + + const aggResult = aggregationForExport(rowsToExport, hasSelection(selectedRows), columns, aggregationResult, aggregationModel); + if (aggResult) { + html += ''; + exportColumns.forEach(col => { + html += aggModel[col.field] + ? `` + : ''; + }); + html += ''; + aggregateTexts(exportColumns, aggResult, aggModel).forEach(text => { + html += text ? `` : ''; + }); + html += ''; + } + } + + html += '
${escapeHTML(col.headerName || col.field)}
${img}
${img}${prefixHtml}${escapeHTML(text)}
${indent}${escapeHTML(groupHeaderLabel(entry, columns))}
${isGrand ? '' : indent}${escapeHTML(summaryLabelText(isGrand ? 'Grand Total' : 'Subtotal', texts[0]))}${escapeHTML(texts[i])}
${escapeHTML(aggModel[col.field].toUpperCase())}
${escapeHTML(text)}
'; + + // Write the HTML content + printWindow.document.open(); + printWindow.document.write(html); + printWindow.document.close(); + + // Set the document title explicitly (fixes about:blank issue) + printWindow.document.title = title || 'Print Preview'; + + printWindow.focus(); + + let hasPrinted = false; + + printWindow.onload = () => { + if (!hasPrinted) { + hasPrinted = true; + printWindow.print(); + + setTimeout(() => { + printWindow.close(); + }, 100); + } + }; + + setTimeout(() => { + if (!hasPrinted && printWindow && !printWindow.closed) { + hasPrinted = true; + printWindow.print(); + setTimeout(() => { + if (!printWindow.closed) printWindow.close(); + }, 100); + } + }, 500); + + } catch (e) { + console.error('Print generation failed:', e); + if (printWindow && !printWindow.closed) { + const message = e instanceof Error ? e.message : String(e); + printWindow.document.body.innerHTML = `

Error Generating Print Preview

${escapeHTML(message)}

`; + } + } +} From f51c29e53dd69a12b8f02fa7f3c0aa4c6141b6b9 Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 16:52:14 +0530 Subject: [PATCH 09/18] refactor(export): move the xlsx cell, format and image helpers to excelCells exportToExcelAdvanced.ts 746 -> 526 lines; public types stay where they are --- lib/utils/export/excelCells.ts | 244 ++++++++++++++++++++ lib/utils/export/exportToExcelAdvanced.ts | 264 ++-------------------- 2 files changed, 266 insertions(+), 242 deletions(-) create mode 100644 lib/utils/export/excelCells.ts diff --git a/lib/utils/export/excelCells.ts b/lib/utils/export/excelCells.ts new file mode 100644 index 00000000..b12b8e5a --- /dev/null +++ b/lib/utils/export/excelCells.ts @@ -0,0 +1,244 @@ +// Cell values, number formats, widths, images and borders of the .xlsx export (exportToExcelAdvanced). + +import type { GridColDef, GridRowModel } from '../../types'; +import { formatExportValue, getRawExportValue } from './exportShared'; +import type { ExcelColumnStyle } from './exportToExcelAdvanced'; + +/** The only values handed to ExcelJS. Objects are never passed through, because ExcelJS reads + * `{ formula }`, `{ hyperlink }`, `{ richText }` and `{ error }` objects as live cell content. */ +type ExcelCellValue = string | number | boolean | Date | null; + +/** A subtotal / total cell value, with the number format it needs when it differs from the column's. */ +export interface AggregateCell { + value: ExcelCellValue; + numFmt?: string; +} + +type CellKind = 'number' | 'boolean' | 'date' | 'string'; + +function cellKind(colDef: GridColDef, value: unknown): CellKind { + if (colDef.type === 'number') return 'number'; + if (colDef.type === 'boolean') return 'boolean'; + if (colDef.type === 'date') return 'date'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'boolean') return 'boolean'; + if (value instanceof Date) return 'date'; + return 'string'; +} + +/** + * Excel date serials carry no timezone and are read as wall-clock time, while ExcelJS converts a + * Date from its UTC instant. Re-anchor the local wall-clock fields at UTC so the cell shows the + * date and time the grid shows. Invalid dates become null. + */ +export function toExcelDate(date: Date): Date | null { + if (Number.isNaN(date.getTime())) return null; + return new Date(Date.UTC( + date.getFullYear(), date.getMonth(), date.getDate(), + date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds(), + )); +} + +const ISO_DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/; +const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i; +const NUMERIC_STRING = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i; + +/** A date-column value as a Date: null when missing or invalid, undefined when not a date at all. */ +function dateCellValue(value: unknown): Date | null | undefined { + if (value instanceof Date) return toExcelDate(value); + if (typeof value === 'number') return Number.isFinite(value) ? toExcelDate(new Date(value)) : null; + if (typeof value !== 'string') return undefined; + const text = value.trim(); + const dateOnly = ISO_DATE_ONLY.exec(text); + if (dateOnly) { + // A calendar date: already wall-clock, so build it at UTC directly. + const [year, month, day] = [Number(dateOnly[1]), Number(dateOnly[2]), Number(dateOnly[3])]; + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : undefined; + } + if (ISO_DATE_TIME.test(text)) { + const date = new Date(text.replace(' ', 'T')); + return Number.isNaN(date.getTime()) ? undefined : toExcelDate(date); + } + return undefined; +} + +/** + * A native value for a number / boolean / date cell: null for an empty cell (missing, NaN, + * Infinity, Invalid Date), undefined when the value is not of that kind (e.g. 'n/a' in a + * number column). + */ +function typedCellValue(kind: Exclude, value: unknown): number | boolean | Date | null | undefined { + if (value == null || value === '') return null; + if (kind === 'number') { + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (typeof value === 'bigint') { + const n = Number(value); + return Number.isSafeInteger(n) ? n : undefined; + } + if (typeof value === 'string' && NUMERIC_STRING.test(value.trim())) { + const n = Number(value.trim()); + return Number.isFinite(n) ? n : undefined; + } + return undefined; + } + if (kind === 'boolean') { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + const text = value.trim().toLowerCase(); + if (text === 'true') return true; + if (text === 'false') return false; + } + return undefined; + } + return dateCellValue(value); +} + +/** Any value as inert cell content: primitives and Dates as themselves, objects as JSON text. */ +function inertCellValue(value: unknown): ExcelCellValue { + if (value == null) return null; + if (typeof value === 'string' || typeof value === 'boolean') return value; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + if (value instanceof Date) return toExcelDate(value); + if (typeof value === 'object') { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } + } + return String(value); +} + +/** + * The value of a data cell. Number, boolean and date cells are native (their `valueFormatter` is + * not used, so Excel's number format applies); a value that is missing or not of the column's + * kind falls back to the text the grid shows. Other cells get the `valueFormatter` text. + */ +export function dataCellValue(row: GridRowModel, col: GridColDef): ExcelCellValue { + const raw = getRawExportValue(row, col); + const kind = cellKind(col, raw); + if (kind !== 'string') { + const typed = typedCellValue(kind, raw); + if (typed != null) return typed; + if (!col.valueFormatter) return typed === null ? null : inertCellValue(raw); + } + return col.valueFormatter ? formatExportValue(row, col, raw) : inertCellValue(raw); +} + +// ─── Number formats ────────────────────────────────────────────────────────── + +export const INTEGER_FORMAT = '#,##0'; +const DECIMAL_FORMAT = '#,##0.##'; + +/** + * Default format for a number-column value. '#,##0.##' alone shows whole numbers with a trailing + * decimal point in Excel ("5."), so whole numbers get '#,##0'. + */ +function defaultNumberFormat(value: number): string { + return Number.isInteger(value) ? INTEGER_FORMAT : DECIMAL_FORMAT; +} + +export function columnNumFmt(colDef: GridColDef, style: ExcelColumnStyle | undefined): string | undefined { + if (style?.numFmt) return style.numFmt; + if (colDef.type === 'date') return 'yyyy-mm-dd'; + return undefined; +} + +/** The number format for a native cell value, or undefined to keep the column's format. */ +export function cellNumFmt(colDef: GridColDef, style: ExcelColumnStyle | undefined, value: ExcelCellValue): string | undefined { + if (style?.numFmt || typeof value !== 'number' || colDef.type !== 'number') return undefined; + return defaultNumberFormat(value); +} + +// ─── Column display width (chars) ──────────────────────────────────────────── + +export function colWidth(colDef: GridColDef, override?: number): number { + if (override != null) return override; + const px = (colDef.width ?? 120) as number; + return Math.min(60, Math.max(8, (px - 5) / 7)); +} + +// ─── Image helpers ──────────────────────────────────────────────────────────── + +type SupportedImageExt = 'png' | 'jpeg' | 'gif'; + +/** + * The image format from the file's magic bytes. Excel can only show PNG, JPEG and GIF here; SVG, + * WebP, AVIF and anything else return null so the URL text is written instead. + */ +function detectImageExtension(bytes: Uint8Array): SupportedImageExt | null { + if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png'; + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg'; + if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'gif'; + return null; +} + +/** The image URL of an embedImage cell: the cell value after `valueGetter`. */ +export function imageUrlOf(row: GridRowModel, col: GridColDef): string | null { + const value = getRawExportValue(row, col); + return typeof value === 'string' && value ? value : null; +} + +/** + * Fetch a list of image URLs in parallel. + * CORS failures and unsupported formats are logged and skipped — raw URL text is written to the + * cell instead so data is never silently lost. + */ +export async function fetchImages( + urls: string[] +): Promise> { + const settled = await Promise.allSettled( + urls.map(async (url) => { + let bytes: Uint8Array; + if (url.startsWith('data:')) { + // ── data: URI — decode base64 directly, no network needed ────────── + const b64 = url.slice(url.indexOf(',') + 1); + const binary = atob(b64); + bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + } else { + // ── Remote URL — fetch with CORS ────────────────────────────────── + const res = await fetch(url, { mode: 'cors' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + bytes = new Uint8Array(await res.arrayBuffer()); + } + const ext = detectImageExtension(bytes); + if (!ext) throw new Error('unsupported image format (only PNG, JPEG and GIF can be embedded)'); + return { url, buffer: bytes.slice().buffer, ext }; + }) + ); + const map = new Map(); + settled.forEach((result, i) => { + if (result.status === 'fulfilled') { + map.set(result.value.url, { buffer: result.value.buffer, extension: result.value.ext }); + } else { + console.warn( + `[exportToExcelAdvanced] Cannot embed image "${urls[i]}" ` + + `(CORS/network error or unsupported format — raw URL written as fallback):`, + result.reason + ); + } + }); + return map; +} + +/** Convert '#rrggbb' or '#rgb' to ExcelJS ARGB 'FFrrggbb' */ +export function argb(hex: string): string { + const clean = hex.replace('#', ''); + if (clean.length === 3) { + return 'FF' + clean.split('').map(c => c + c).join(''); + } + if (clean.length === 6) { + return 'FF' + clean; + } + return 'FF' + clean.padEnd(6, '0'); +} + +export function bottomBorder(): Partial { + return { bottom: { style: 'medium', color: { argb: 'FFcbd5e1' } } }; +} + +export function topBorder(): Partial { + return { top: { style: 'medium', color: { argb: 'FFcbd5e1' } } }; +} diff --git a/lib/utils/export/exportToExcelAdvanced.ts b/lib/utils/export/exportToExcelAdvanced.ts index b3bb1517..70815685 100644 --- a/lib/utils/export/exportToExcelAdvanced.ts +++ b/lib/utils/export/exportToExcelAdvanced.ts @@ -1,18 +1,37 @@ -import type { GridColDef, GridRowId, GridRowModel, GridValidRowModel, GridAggregationModel, GridGroupedExportRow } from '../../types'; +import type { + GridColDef, + GridRowId, + GridRowModel, + GridValidRowModel, + GridAggregationModel, + GridGroupedExportRow, +} from '../../types'; import { groupHeaderLabel } from './groupLabel'; import { aggregationForExport, formatExportAggregate, - formatExportValue, getExportColumns, - getRawExportValue, hasSelection, isCountAggregation, normalizeAggregateValue, pickSelectedRows, sanitizeSheetName, } from './exportShared'; +import { + AggregateCell, + toExcelDate, + dataCellValue, + INTEGER_FORMAT, + columnNumFmt, + cellNumFmt, + colWidth, + imageUrlOf, + fetchImages, + argb, + bottomBorder, + topBorder, +} from './excelCells'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -105,225 +124,6 @@ export interface ExcelAdvancedExportOptions, value: unknown): CellKind { - if (colDef.type === 'number') return 'number'; - if (colDef.type === 'boolean') return 'boolean'; - if (colDef.type === 'date') return 'date'; - if (typeof value === 'number') return 'number'; - if (typeof value === 'boolean') return 'boolean'; - if (value instanceof Date) return 'date'; - return 'string'; -} - -/** - * Excel date serials carry no timezone and are read as wall-clock time, while ExcelJS converts a - * Date from its UTC instant. Re-anchor the local wall-clock fields at UTC so the cell shows the - * date and time the grid shows. Invalid dates become null. - */ -function toExcelDate(date: Date): Date | null { - if (Number.isNaN(date.getTime())) return null; - return new Date(Date.UTC( - date.getFullYear(), date.getMonth(), date.getDate(), - date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds(), - )); -} - -const ISO_DATE_ONLY = /^(\d{4})-(\d{2})-(\d{2})$/; -const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/i; -const NUMERIC_STRING = /^[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?$/i; - -/** A date-column value as a Date: null when missing or invalid, undefined when not a date at all. */ -function dateCellValue(value: unknown): Date | null | undefined { - if (value instanceof Date) return toExcelDate(value); - if (typeof value === 'number') return Number.isFinite(value) ? toExcelDate(new Date(value)) : null; - if (typeof value !== 'string') return undefined; - const text = value.trim(); - const dateOnly = ISO_DATE_ONLY.exec(text); - if (dateOnly) { - // A calendar date: already wall-clock, so build it at UTC directly. - const [year, month, day] = [Number(dateOnly[1]), Number(dateOnly[2]), Number(dateOnly[3])]; - const date = new Date(Date.UTC(year, month - 1, day)); - return date.getUTCMonth() === month - 1 && date.getUTCDate() === day ? date : undefined; - } - if (ISO_DATE_TIME.test(text)) { - const date = new Date(text.replace(' ', 'T')); - return Number.isNaN(date.getTime()) ? undefined : toExcelDate(date); - } - return undefined; -} - -/** - * A native value for a number / boolean / date cell: null for an empty cell (missing, NaN, - * Infinity, Invalid Date), undefined when the value is not of that kind (e.g. 'n/a' in a - * number column). - */ -function typedCellValue(kind: Exclude, value: unknown): number | boolean | Date | null | undefined { - if (value == null || value === '') return null; - if (kind === 'number') { - if (typeof value === 'number') return Number.isFinite(value) ? value : null; - if (typeof value === 'bigint') { - const n = Number(value); - return Number.isSafeInteger(n) ? n : undefined; - } - if (typeof value === 'string' && NUMERIC_STRING.test(value.trim())) { - const n = Number(value.trim()); - return Number.isFinite(n) ? n : undefined; - } - return undefined; - } - if (kind === 'boolean') { - if (typeof value === 'boolean') return value; - if (typeof value === 'string') { - const text = value.trim().toLowerCase(); - if (text === 'true') return true; - if (text === 'false') return false; - } - return undefined; - } - return dateCellValue(value); -} - -/** Any value as inert cell content: primitives and Dates as themselves, objects as JSON text. */ -function inertCellValue(value: unknown): ExcelCellValue { - if (value == null) return null; - if (typeof value === 'string' || typeof value === 'boolean') return value; - if (typeof value === 'number') return Number.isFinite(value) ? value : null; - if (value instanceof Date) return toExcelDate(value); - if (typeof value === 'object') { - try { - return JSON.stringify(value) ?? String(value); - } catch { - return String(value); - } - } - return String(value); -} - -/** - * The value of a data cell. Number, boolean and date cells are native (their `valueFormatter` is - * not used, so Excel's number format applies); a value that is missing or not of the column's - * kind falls back to the text the grid shows. Other cells get the `valueFormatter` text. - */ -function dataCellValue(row: GridRowModel, col: GridColDef): ExcelCellValue { - const raw = getRawExportValue(row, col); - const kind = cellKind(col, raw); - if (kind !== 'string') { - const typed = typedCellValue(kind, raw); - if (typed != null) return typed; - if (!col.valueFormatter) return typed === null ? null : inertCellValue(raw); - } - return col.valueFormatter ? formatExportValue(row, col, raw) : inertCellValue(raw); -} - -// ─── Number formats ────────────────────────────────────────────────────────── - -const INTEGER_FORMAT = '#,##0'; -const DECIMAL_FORMAT = '#,##0.##'; - -/** - * Default format for a number-column value. '#,##0.##' alone shows whole numbers with a trailing - * decimal point in Excel ("5."), so whole numbers get '#,##0'. - */ -function defaultNumberFormat(value: number): string { - return Number.isInteger(value) ? INTEGER_FORMAT : DECIMAL_FORMAT; -} - -function columnNumFmt(colDef: GridColDef, style: ExcelColumnStyle | undefined): string | undefined { - if (style?.numFmt) return style.numFmt; - if (colDef.type === 'date') return 'yyyy-mm-dd'; - return undefined; -} - -/** The number format for a native cell value, or undefined to keep the column's format. */ -function cellNumFmt(colDef: GridColDef, style: ExcelColumnStyle | undefined, value: ExcelCellValue): string | undefined { - if (style?.numFmt || typeof value !== 'number' || colDef.type !== 'number') return undefined; - return defaultNumberFormat(value); -} - -// ─── Column display width (chars) ──────────────────────────────────────────── - -function colWidth(colDef: GridColDef, override?: number): number { - if (override != null) return override; - const px = (colDef.width ?? 120) as number; - return Math.min(60, Math.max(8, (px - 5) / 7)); -} - -// ─── Image helpers ──────────────────────────────────────────────────────────── - -type SupportedImageExt = 'png' | 'jpeg' | 'gif'; - -/** - * The image format from the file's magic bytes. Excel can only show PNG, JPEG and GIF here; SVG, - * WebP, AVIF and anything else return null so the URL text is written instead. - */ -function detectImageExtension(bytes: Uint8Array): SupportedImageExt | null { - if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'png'; - if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'jpeg'; - if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38) return 'gif'; - return null; -} - -/** The image URL of an embedImage cell: the cell value after `valueGetter`. */ -function imageUrlOf(row: GridRowModel, col: GridColDef): string | null { - const value = getRawExportValue(row, col); - return typeof value === 'string' && value ? value : null; -} - -/** - * Fetch a list of image URLs in parallel. - * CORS failures and unsupported formats are logged and skipped — raw URL text is written to the - * cell instead so data is never silently lost. - */ -async function fetchImages( - urls: string[] -): Promise> { - const settled = await Promise.allSettled( - urls.map(async (url) => { - let bytes: Uint8Array; - if (url.startsWith('data:')) { - // ── data: URI — decode base64 directly, no network needed ────────── - const b64 = url.slice(url.indexOf(',') + 1); - const binary = atob(b64); - bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - } else { - // ── Remote URL — fetch with CORS ────────────────────────────────── - const res = await fetch(url, { mode: 'cors' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - bytes = new Uint8Array(await res.arrayBuffer()); - } - const ext = detectImageExtension(bytes); - if (!ext) throw new Error('unsupported image format (only PNG, JPEG and GIF can be embedded)'); - return { url, buffer: bytes.slice().buffer, ext }; - }) - ); - const map = new Map(); - settled.forEach((result, i) => { - if (result.status === 'fulfilled') { - map.set(result.value.url, { buffer: result.value.buffer, extension: result.value.ext }); - } else { - console.warn( - `[exportToExcelAdvanced] Cannot embed image "${urls[i]}" ` + - `(CORS/network error or unsupported format — raw URL written as fallback):`, - result.reason - ); - } - }); - return map; -} - // ─── Main export function ───────────────────────────────────────────────────── /** @@ -724,23 +524,3 @@ export async function exportToExcelAdvanced( } // ─── Helpers ────────────────────────────────────────────────────────────────── - -/** Convert '#rrggbb' or '#rgb' to ExcelJS ARGB 'FFrrggbb' */ -function argb(hex: string): string { - const clean = hex.replace('#', ''); - if (clean.length === 3) { - return 'FF' + clean.split('').map(c => c + c).join(''); - } - if (clean.length === 6) { - return 'FF' + clean; - } - return 'FF' + clean.padEnd(6, '0'); -} - -function bottomBorder(): Partial { - return { bottom: { style: 'medium', color: { argb: 'FFcbd5e1' } } }; -} - -function topBorder(): Partial { - return { top: { style: 'medium', color: { argb: 'FFcbd5e1' } } }; -} From 74009c24a8558665a561500f8b102c65aed5c6ea Mon Sep 17 00:00:00 2001 From: Asif Ansari Date: Tue, 13 Oct 2026 16:58:12 +0530 Subject: [PATCH 10/18] refactor(cell-selection): move the range drag into useGridCellRangeDrag Click, Shift+click, drag with auto-scroll and the fill handle's tracked drags; called from useGridCellSelection, so the hook order is unchanged. useGridCellSelection.ts 711 -> 431 lines --- CLAUDE.md | 1 + lib/hooks/core/useGridCellRangeDrag.ts | 345 +++++++++++++++++++++++++ lib/hooks/core/useGridCellSelection.ts | 314 ++-------------------- 3 files changed, 363 insertions(+), 297 deletions(-) create mode 100644 lib/hooks/core/useGridCellRangeDrag.ts diff --git a/CLAUDE.md b/CLAUDE.md index 63ee55f5..52cfa610 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,6 +161,7 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs` - **`npm run check:package`** (after `build:lib`; in `ci.yml` and `npm-publish.yml`): fails if `npm pack` would ship a test artifact (`*.test.*`, `__baseline__`, `__screenshots__`, `__snapshots__`, `__fixtures__`) and if `DataGrid` alone, minified with esbuild and gzipped, exceeds `DATAGRID_GZIP_BUDGET` (92 KB; 85.5 KB at 3.9.0). `files` in package.json works by exclusion: add a negation for any new kind of test folder under `lib/`. Raise the budget only in the commit that needs it ## v3.10.0 — significant changes (unreleased) +- R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`) - Demo code samples go through `demo/components/CodeBlock.tsx` (prism-react-renderer, demo-only devDependency); live output panels (JSON state, logs) stay plain `
`
 
 ## v3.9.0 — significant changes
diff --git a/lib/hooks/core/useGridCellRangeDrag.ts b/lib/hooks/core/useGridCellRangeDrag.ts
new file mode 100644
index 00000000..eba0d70c
--- /dev/null
+++ b/lib/hooks/core/useGridCellRangeDrag.ts
@@ -0,0 +1,345 @@
+import {
+    useCallback,
+    useEffect,
+    useLayoutEffect,
+    useRef,
+} from 'react';
+import type { RefObject } from 'react';
+import { isRtl } from '../../utils/direction';
+import { isTextEntryElement } from '../../utils/focus';
+import type { FocusedCell } from '../../utils/focus';
+import { isRangeColumnField, isSameCoordinates } from '../../utils/cellSelection';
+import type { CellRangeContext } from '../../utils/cellSelection';
+import type {
+    GridCellCoordinates,
+    GridCellSelectionModel,
+    GridCellSelectionReason,
+    GridRowModel,
+} from '../../types';
+
+/** Root class while a pointer drag selects cells: no text selection meanwhile. Public from v3.3. */
+export const RANGE_DRAGGING_CLASS = 'ogx--range-dragging';
+
+/** Pointer distance (px) from a body edge at which a drag starts scrolling. */
+import type { GridTrackedDragOptions, UseGridCellSelectionParams } from './useGridCellSelection';
+
+// The cell range drag of useGridCellSelection: click, Shift+click and drag (with auto-scroll past
+// the edges), and tracked drags for the fill handle. Split out of useGridCellSelection (3.10); it is
+// called from there, so the grid's hook order is unchanged.
+
+const AUTO_SCROLL_EDGE = 32;
+const AUTO_SCROLL_MAX_STEP = 40;
+
+interface DragState {
+    anchor: GridCellCoordinates;
+    head: GridCellCoordinates;
+    x: number;
+    y: number;
+    frame: number;
+    moved: boolean;
+    /**
+     * Whether the viewport lays out right to left, read once per drag: `getComputedStyle` every frame
+     * forces a style recalculation, which slowed WebKit's auto-scroll enough to delay the release.
+     */
+    rtl?: boolean;
+    /** Ends the drag; `cancelled` unless the button was released normally (tracked drags: Escape cancels). */
+    stop: (cancelled?: boolean, altKey?: boolean) => void;
+    /** A tracked drag (fill handle): told the cell under the pointer each frame instead of moving the range. */
+    onTrack?: (head: GridCellCoordinates, x: number, y: number) => void;
+}
+
+/** The body cell of `viewport` holding `target`: its row index and field. Not cells of nested grids or detail panels. */
+function cellFromTarget(target: EventTarget | null, viewport: HTMLElement): { rowIndex: number; field: string; element: HTMLElement } | null {
+    if (!target || (target as Node).nodeType !== 1) return null;
+    const element = (target as Element).closest('.ogx__cell[data-field]');
+    if (!element || element.closest('.ogx__viewport') !== viewport) return null;
+    const panel = element.closest('.ogx__detail-panel');
+    if (panel && viewport.contains(panel)) return null;
+    const rowElement = element.closest('[role="row"][data-rowindex]');
+    if (!rowElement) return null;
+    const rowIndex = Number(rowElement.getAttribute('data-rowindex'));
+    const field = element.dataset.field;
+    if (!Number.isInteger(rowIndex) || !field) return null;
+    return { rowIndex, field, element };
+}
+
+function clearTextSelection(doc: Document): void {
+    const selection = doc.getSelection();
+    if (selection && selection.rangeCount > 0) selection.removeAllRanges();
+}
+
+const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
+const autoScrollStep = (over: number) => Math.min(AUTO_SCROLL_MAX_STEP, Math.max(2, Math.round(over / 2)));
+
+/** What the drag reads from the selection hook's latest values at event time. */
+export interface CellRangeDragLatest {
+    enabled: boolean;
+    context: CellRangeContext | null;
+    layout: UseGridCellSelectionParams['layout'];
+    pinnedTopRowCount: number;
+    viewportRef: RefObject;
+    containerRef: RefObject;
+}
+
+export interface UseGridCellRangeDragParams {
+    latestRef: { readonly current: CellRangeDragLatest };
+    focusRef: { readonly current: FocusedCell | null };
+    setRange: (anchor: GridCellCoordinates, head: GridCellCoordinates, reason: GridCellSelectionReason) => void;
+    currentModel: () => GridCellSelectionModel;
+}
+
+export function useGridCellRangeDrag(params: UseGridCellRangeDragParams) {
+    const { latestRef, focusRef, setRange, currentModel } = params;
+    // ── Pointer: click, Shift+click and drag ────────────────────────────────
+    const dragRef = useRef(null);
+    const suppressClickRef = useRef(false);
+
+    useEffect(() => () => dragRef.current?.stop(), []);
+
+    const headAtPoint = useCallback((viewport: HTMLElement, x: number, y: number): GridCellCoordinates | null => {
+        const ctx = latestRef.current.context;
+        if (!ctx) return null;
+        const doc = viewport.ownerDocument;
+        // jsdom has no elementFromPoint.
+        if (typeof doc.elementFromPoint !== 'function') return null;
+        const hit = cellFromTarget(doc.elementFromPoint(x, y), viewport);
+        if (!hit) return null;
+        const id = ctx.rowIds[hit.rowIndex];
+        if (id === undefined) return null;
+        // Over the system or grouping column (left of the data): the first data column.
+        const field = isRangeColumnField(hit.field) ? hit.field : ctx.fields[0];
+        return field === undefined ? null : { id, field };
+    }, [latestRef]);
+
+    /** The row whose band holds `centerY` (px from the top of the unpinned rows), with `field`. */
+    const headRowAtOffset = useCallback((centerY: number, field: string): GridCellCoordinates | null => {
+        const { context: ctx, layout, pinnedTopRowCount } = latestRef.current;
+        const heights = layout.cumulativeHeights;
+        if (!ctx || heights.length === 0 || centerY < 0) return null;
+        let lo = 0;
+        let hi = heights.length - 1;
+        while (lo < hi) {
+            const mid = (lo + hi) >> 1;
+            if (heights[mid] > centerY) hi = mid; else lo = mid + 1;
+        }
+        const id = ctx.rowIds[lo + pinnedTopRowCount];
+        return id === undefined ? null : { id, field };
+    }, [latestRef]);
+
+    // The auto-scroll loop schedules the next frame through this ref: a callback cannot name itself.
+    const runDragFrameRef = useRef<(allowScroll: boolean) => void>(() => {});
+    const runDragFrame = useCallback((allowScroll: boolean) => {
+        const drag = dragRef.current;
+        const viewport = latestRef.current.viewportRef.current;
+        if (!drag || !viewport) return;
+        drag.frame = 0;
+        const { layout } = latestRef.current;
+        const box = viewport.getBoundingClientRect();
+        const width = viewport.clientWidth;
+        const height = viewport.clientHeight;
+        const stickyTop = viewport.querySelector(':scope > .ogx__content > .ogx__sticky-top');
+        const stickyBottom = viewport.querySelector(':scope > .ogx__content > .ogx__sticky-bottom');
+        const headerWrap = stickyTop?.querySelector(':scope > .ogx__header-wrap');
+        const bodyTop = stickyTop ? stickyTop.getBoundingClientRect().bottom : box.top;
+        const bodyBottom = Math.min(box.top + height, stickyBottom ? stickyBottom.getBoundingClientRect().top : box.top + height);
+        const headerBottom = headerWrap ? headerWrap.getBoundingClientRect().bottom : bodyTop;
+        // Left-pinned columns (and pinned system columns) sit at the inline start: on the right in a
+        // right-to-left grid, whose vertical scrollbar is on the left.
+        if (drag.rtl === undefined) drag.rtl = isRtl(viewport);
+        const rtl = drag.rtl;
+        const contentLeft = rtl ? box.right - width : box.left;
+        const startPinnedWidth = layout.pinnedSystemColumnsWidth + layout.leftWidth;
+        const bodyLeft = contentLeft + (rtl ? layout.rightWidth : startPinnedWidth);
+        const bodyRight = contentLeft + width - (rtl ? startPinnedWidth : layout.rightWidth);
+
+        // The cell under the pointer; outside the viewport (or over the header) the nearest body cell.
+        const x = clamp(drag.x, contentLeft + 1, contentLeft + width - 2);
+        let y = drag.y;
+        if (y < box.top || y > box.top + height || y < headerBottom) y = clamp(y, bodyTop + 1, Math.max(bodyTop + 1, bodyBottom - 2));
+        // During fast auto-scroll the rows under the pointer may not be rendered yet (virtualization
+        // lags a frame or more on a slow machine), so the hit test finds no cell. Work out the row from
+        // the scroll position and the row heights instead, keeping the column the head already has.
+        const canHitTest = typeof viewport.ownerDocument.elementFromPoint === 'function';
+        const hit = headAtPoint(viewport, x, y);
+        const head = hit ?? (canHitTest && drag.moved
+            ? headRowAtOffset(y - box.top - (stickyTop ? stickyTop.getBoundingClientRect().height : 0) + viewport.scrollTop, drag.head.field)
+            : null);
+        if (head && !isSameCoordinates(head, drag.head)) {
+            drag.head = head;
+            if (!drag.moved && !isSameCoordinates(head, drag.anchor)) {
+                drag.moved = true;
+                latestRef.current.containerRef.current?.classList.add(RANGE_DRAGGING_CLASS);
+                clearTextSelection(viewport.ownerDocument);
+            }
+            if (!drag.onTrack) setRange(drag.anchor, head, 'pointer');
+        }
+        // A tracked drag hears every frame: its target can change while the head stays in one cell.
+        if (drag.onTrack) drag.onTrack(drag.head, drag.x, drag.y);
+
+        if (!allowScroll) return;
+        const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x < contentLeft || drag.x > contentLeft + width;
+        if (!drag.moved && !outside) return;
+        let dy = 0;
+        let dx = 0;
+        if (drag.y < bodyTop + AUTO_SCROLL_EDGE) dy = -autoScrollStep(bodyTop + AUTO_SCROLL_EDGE - drag.y);
+        else if (drag.y > bodyBottom - AUTO_SCROLL_EDGE) dy = autoScrollStep(drag.y - (bodyBottom - AUTO_SCROLL_EDGE));
+        if (drag.x < bodyLeft + AUTO_SCROLL_EDGE) dx = -autoScrollStep(bodyLeft + AUTO_SCROLL_EDGE - drag.x);
+        else if (drag.x > bodyRight - AUTO_SCROLL_EDGE) dx = autoScrollStep(drag.x - (bodyRight - AUTO_SCROLL_EDGE));
+        const canScrollY = (dy < 0 && viewport.scrollTop > 0) || (dy > 0 && viewport.scrollTop + height < viewport.scrollHeight - 1);
+        // scrollLeft runs from 0 to the maximum, or from minus the maximum to 0 right to left.
+        const maxScroll = viewport.scrollWidth - width;
+        const minLeft = rtl ? -maxScroll : 0;
+        const canScrollX = (dx < 0 && viewport.scrollLeft > minLeft) || (dx > 0 && viewport.scrollLeft < minLeft + maxScroll - 1);
+        if (!canScrollY && !canScrollX) return;
+        if (canScrollY) viewport.scrollTop += dy;
+        if (canScrollX) viewport.scrollLeft += dx;
+        // Keep going while the pointer rests in the edge zone; the next frame picks the new head.
+        drag.frame = requestAnimationFrame(() => runDragFrameRef.current(true));
+    }, [latestRef, headAtPoint, headRowAtOffset, setRange]);
+    useLayoutEffect(() => {
+        runDragFrameRef.current = runDragFrame;
+    }, [runDragFrame]);
+
+    const startDrag = useCallback((
+        anchor: GridCellCoordinates,
+        head: GridCellCoordinates,
+        x: number,
+        y: number,
+        doc: Document,
+        tracked?: Pick,
+    ) => {
+        dragRef.current?.stop();
+        // A range drag follows the mouse; a tracked drag (fill handle) follows its pointer, touch included.
+        const moveType = tracked ? 'pointermove' : 'mousemove';
+        const upType = tracked ? 'pointerup' : 'mouseup';
+        const isOwnEvent = (event: MouseEvent) => !tracked || (event as PointerEvent).pointerId === tracked.pointerId;
+        const onMove = (event: MouseEvent) => {
+            const drag = dragRef.current;
+            if (!drag || !isOwnEvent(event)) return;
+            if (event.buttons === 0) {
+                // Released outside the window.
+                drag.stop();
+                return;
+            }
+            drag.x = event.clientX;
+            drag.y = event.clientY;
+            // The native text selection started by the press would otherwise follow the pointer out
+            // of the grid (WebKit), and a page text selection makes Ctrl/Cmd+C leave the copy to the browser.
+            if (drag.moved) {
+                event.preventDefault();
+                clearTextSelection(doc);
+            }
+            if (!drag.frame) drag.frame = requestAnimationFrame(() => runDragFrame(true));
+        };
+        const onUp = (event: MouseEvent) => {
+            const drag = dragRef.current;
+            if (!drag || !isOwnEvent(event)) return;
+            drag.x = event.clientX;
+            drag.y = event.clientY;
+            runDragFrame(false);
+            const moved = drag.moved;
+            drag.stop(false, event.altKey);
+            if (moved) clearTextSelection(doc);
+            if (moved) {
+                // The click that follows lands on the common ancestor of both cells (often the row):
+                // it must not select the row or move focus.
+                suppressClickRef.current = true;
+                setTimeout(() => { suppressClickRef.current = false; }, 0);
+            }
+        };
+        const onCancel = (event: Event) => {
+            if (isOwnEvent(event as MouseEvent)) dragRef.current?.stop();
+        };
+        // Escape abandons a tracked drag (the fill writes nothing).
+        const onKeyDown = (event: KeyboardEvent) => {
+            if (event.key !== 'Escape' || !dragRef.current) return;
+            event.preventDefault();
+            event.stopPropagation();
+            dragRef.current.stop();
+        };
+        const view = doc.defaultView;
+        const onBlur = () => dragRef.current?.stop();
+        let ended = false;
+        const drag: DragState = {
+            anchor, head, x, y, frame: 0, moved: false,
+            onTrack: tracked?.onTrack,
+            stop: (cancelled = true, altKey = false) => {
+                if (drag.frame) cancelAnimationFrame(drag.frame);
+                drag.frame = 0;
+                doc.removeEventListener(moveType, onMove, true);
+                doc.removeEventListener(upType, onUp, true);
+                if (tracked) {
+                    doc.removeEventListener('pointercancel', onCancel, true);
+                    doc.removeEventListener('keydown', onKeyDown, true);
+                }
+                view?.removeEventListener('blur', onBlur);
+                latestRef.current.containerRef.current?.classList.remove(RANGE_DRAGGING_CLASS);
+                if (dragRef.current === drag) dragRef.current = null;
+                if (!ended) {
+                    ended = true;
+                    if (tracked) tracked.onEnd({ cancelled, altKey });
+                }
+            },
+        };
+        dragRef.current = drag;
+        doc.addEventListener(moveType, onMove, true);
+        doc.addEventListener(upType, onUp, true);
+        if (tracked) {
+            doc.addEventListener('pointercancel', onCancel, true);
+            doc.addEventListener('keydown', onKeyDown, true);
+        }
+        view?.addEventListener('blur', onBlur);
+    }, [latestRef, runDragFrame]);
+
+    const startTrackedDrag = useCallback((options: GridTrackedDragOptions) => {
+        const viewport = latestRef.current.viewportRef.current;
+        if (!viewport || !latestRef.current.enabled) return;
+        const { start, x, y, pointerId, onTrack, onEnd } = options;
+        startDrag(start, start, x, y, viewport.ownerDocument, { pointerId, onTrack, onEnd });
+    }, [latestRef, startDrag]);
+
+    const handleMouseDown = useCallback((event: React.MouseEvent) => {
+        if (event.button !== 0 || event.defaultPrevented) return;
+        const { context: ctx } = latestRef.current;
+        if (!ctx) return;
+        const viewport = event.currentTarget;
+        const hit = cellFromTarget(event.target, viewport);
+        if (!hit || !isRangeColumnField(hit.field)) return;
+        // The fill handle starts its own drag (useGridFillHandle).
+        if ((event.target as Element).closest?.('.ogx__cell-fill-handle')) return;
+        // Typing targets and open editors keep the pointer to themselves.
+        if (hit.element.classList.contains('ogx__cell--editing') || isTextEntryElement(event.target as Element)) return;
+        const id = ctx.rowIds[hit.rowIndex];
+        if (id === undefined) return;
+        const cell = { id, field: hit.field };
+
+        let anchor: GridCellCoordinates = cell;
+        if (event.shiftKey) {
+            const current = currentModel()[0];
+            const focus = focusRef.current;
+            if (current && ctx.rowIndexById.has(current.anchor.id) && ctx.colIndexByField.has(current.anchor.field)) anchor = current.anchor;
+            else if (focus && focus.id !== null && isRangeColumnField(focus.field)) anchor = { id: focus.id, field: focus.field };
+            // Shift+click extends the range: no text selection, no focus move and no click (row
+            // selection, onCellClick) afterwards.
+            event.preventDefault();
+            suppressClickRef.current = true;
+            const doc = viewport.ownerDocument;
+            if (!viewport.contains(doc.activeElement)) viewport.focus({ preventScroll: true });
+        }
+        setRange(anchor, cell, 'pointer');
+        startDrag(anchor, cell, event.clientX, event.clientY, viewport.ownerDocument);
+        if (event.shiftKey) {
+            const drag = dragRef.current;
+            if (drag && !isSameCoordinates(anchor, cell)) drag.moved = true;
+        }
+    }, [focusRef, latestRef, currentModel, setRange, startDrag]);
+
+    const handleClickCapture = useCallback((event: React.MouseEvent) => {
+        if (!suppressClickRef.current) return;
+        suppressClickRef.current = false;
+        event.stopPropagation();
+        event.preventDefault();
+    }, []);
+
+    return { startTrackedDrag, handleMouseDown, handleClickCapture };
+}
diff --git a/lib/hooks/core/useGridCellSelection.ts b/lib/hooks/core/useGridCellSelection.ts
index 655b504c..775668da 100644
--- a/lib/hooks/core/useGridCellSelection.ts
+++ b/lib/hooks/core/useGridCellSelection.ts
@@ -1,10 +1,15 @@
-import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
+import {
+    useCallback,
+    useEffect,
+    useLayoutEffect,
+    useMemo,
+    useRef,
+    useState,
+} from 'react';
 import type { RefObject } from 'react';
 import { getRowIndex } from '../../utils/rowIndex';
 import { scrollColumnIntoView, scrollRowIntoView } from '../../utils/scroll';
-import { isRtl } from '../../utils/direction';
 import { isSyntheticRowId } from '../../utils/syntheticRows';
-import { isTextEntryElement } from '../../utils/focus';
 import type { FocusedCell } from '../../utils/focus';
 import { getCellValue } from '../../utils/values';
 import { AGGREGATION_FUNCTIONS } from '../../utils/aggregation';
@@ -18,7 +23,12 @@ import {
     rectCellCount,
     resolveCellRange,
 } from '../../utils/cellSelection';
-import type { CellRangeContext, CellRangeRect, CellRangeSpanLookup, ResolvedCellRange } from '../../utils/cellSelection';
+import type {
+    CellRangeContext,
+    CellRangeRect,
+    CellRangeSpanLookup,
+    ResolvedCellRange,
+} from '../../utils/cellSelection';
 import { buildRangeTsv } from '../features/useGridClipboard';
 import type { GridSpanningResult } from '../features/useGridSpanning';
 import type { LayoutResult } from './useLayout';
@@ -34,13 +44,12 @@ import type {
     GridRowModel,
     GridSelectedCell,
 } from '../../types';
+import { useGridCellRangeDrag } from './useGridCellRangeDrag';
 
 /** Root class while a pointer drag selects cells: no text selection meanwhile. Public from v3.3. */
 export const RANGE_DRAGGING_CLASS = 'ogx--range-dragging';
 
 /** Pointer distance (px) from a body edge at which a drag starts scrolling. */
-const AUTO_SCROLL_EDGE = 32;
-const AUTO_SCROLL_MAX_STEP = 40;
 const ANNOUNCEMENT_DELAY_MS = 300;
 
 /** The rectangle in the coordinates rows render with: row indices of the renderable rows, data column indices. */
@@ -127,24 +136,6 @@ export interface UseGridCellSelectionReturn {
     startTrackedDrag: (options: GridTrackedDragOptions) => void;
 }
 
-interface DragState {
-    anchor: GridCellCoordinates;
-    head: GridCellCoordinates;
-    x: number;
-    y: number;
-    frame: number;
-    moved: boolean;
-    /**
-     * Whether the viewport lays out right to left, read once per drag: `getComputedStyle` every frame
-     * forces a style recalculation, which slowed WebKit's auto-scroll enough to delay the release.
-     */
-    rtl?: boolean;
-    /** Ends the drag; `cancelled` unless the button was released normally (tracked drags: Escape cancels). */
-    stop: (cancelled?: boolean, altKey?: boolean) => void;
-    /** A tracked drag (fill handle): told the cell under the pointer each frame instead of moving the range. */
-    onTrack?: (head: GridCellCoordinates, x: number, y: number) => void;
-}
-
 /** A drag that reuses the range drag's hit testing and auto-scroll without selecting (the fill handle, v3.5). */
 export interface GridTrackedDragOptions {
     /** The cell the drag starts on. */
@@ -159,29 +150,6 @@ export interface GridTrackedDragOptions {
     onEnd: (details: { cancelled: boolean; altKey: boolean }) => void;
 }
 
-/** The body cell of `viewport` holding `target`: its row index and field. Not cells of nested grids or detail panels. */
-function cellFromTarget(target: EventTarget | null, viewport: HTMLElement): { rowIndex: number; field: string; element: HTMLElement } | null {
-    if (!target || (target as Node).nodeType !== 1) return null;
-    const element = (target as Element).closest('.ogx__cell[data-field]');
-    if (!element || element.closest('.ogx__viewport') !== viewport) return null;
-    const panel = element.closest('.ogx__detail-panel');
-    if (panel && viewport.contains(panel)) return null;
-    const rowElement = element.closest('[role="row"][data-rowindex]');
-    if (!rowElement) return null;
-    const rowIndex = Number(rowElement.getAttribute('data-rowindex'));
-    const field = element.dataset.field;
-    if (!Number.isInteger(rowIndex) || !field) return null;
-    return { rowIndex, field, element };
-}
-
-function clearTextSelection(doc: Document): void {
-    const selection = doc.getSelection();
-    if (selection && selection.rangeCount > 0) selection.removeAllRanges();
-}
-
-const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
-const autoScrollStep = (over: number) => Math.min(AUTO_SCROLL_MAX_STEP, Math.max(2, Math.round(over / 2)));
-
 function warnLargeCopy(cellCount: number): void {
     if (process.env.NODE_ENV === 'production') return;
     console.warn(
@@ -400,256 +368,8 @@ export function useGridCellSelection(params: UseGridCell
         return cells;
     }, [currentRange]);
 
-    // ── Pointer: click, Shift+click and drag ────────────────────────────────
-    const dragRef = useRef(null);
-    const suppressClickRef = useRef(false);
-
-    useEffect(() => () => dragRef.current?.stop(), []);
-
-    const headAtPoint = useCallback((viewport: HTMLElement, x: number, y: number): GridCellCoordinates | null => {
-        const ctx = latestRef.current.context;
-        if (!ctx) return null;
-        const doc = viewport.ownerDocument;
-        // jsdom has no elementFromPoint.
-        if (typeof doc.elementFromPoint !== 'function') return null;
-        const hit = cellFromTarget(doc.elementFromPoint(x, y), viewport);
-        if (!hit) return null;
-        const id = ctx.rowIds[hit.rowIndex];
-        if (id === undefined) return null;
-        // Over the system or grouping column (left of the data): the first data column.
-        const field = isRangeColumnField(hit.field) ? hit.field : ctx.fields[0];
-        return field === undefined ? null : { id, field };
-    }, []);
-
-    /** The row whose band holds `centerY` (px from the top of the unpinned rows), with `field`. */
-    const headRowAtOffset = useCallback((centerY: number, field: string): GridCellCoordinates | null => {
-        const { context: ctx, layout, pinnedTopRowCount } = latestRef.current;
-        const heights = layout.cumulativeHeights;
-        if (!ctx || heights.length === 0 || centerY < 0) return null;
-        let lo = 0;
-        let hi = heights.length - 1;
-        while (lo < hi) {
-            const mid = (lo + hi) >> 1;
-            if (heights[mid] > centerY) hi = mid; else lo = mid + 1;
-        }
-        const id = ctx.rowIds[lo + pinnedTopRowCount];
-        return id === undefined ? null : { id, field };
-    }, []);
-
-    // The auto-scroll loop schedules the next frame through this ref: a callback cannot name itself.
-    const runDragFrameRef = useRef<(allowScroll: boolean) => void>(() => {});
-    const runDragFrame = useCallback((allowScroll: boolean) => {
-        const drag = dragRef.current;
-        const viewport = latestRef.current.viewportRef.current;
-        if (!drag || !viewport) return;
-        drag.frame = 0;
-        const { layout } = latestRef.current;
-        const box = viewport.getBoundingClientRect();
-        const width = viewport.clientWidth;
-        const height = viewport.clientHeight;
-        const stickyTop = viewport.querySelector(':scope > .ogx__content > .ogx__sticky-top');
-        const stickyBottom = viewport.querySelector(':scope > .ogx__content > .ogx__sticky-bottom');
-        const headerWrap = stickyTop?.querySelector(':scope > .ogx__header-wrap');
-        const bodyTop = stickyTop ? stickyTop.getBoundingClientRect().bottom : box.top;
-        const bodyBottom = Math.min(box.top + height, stickyBottom ? stickyBottom.getBoundingClientRect().top : box.top + height);
-        const headerBottom = headerWrap ? headerWrap.getBoundingClientRect().bottom : bodyTop;
-        // Left-pinned columns (and pinned system columns) sit at the inline start: on the right in a
-        // right-to-left grid, whose vertical scrollbar is on the left.
-        if (drag.rtl === undefined) drag.rtl = isRtl(viewport);
-        const rtl = drag.rtl;
-        const contentLeft = rtl ? box.right - width : box.left;
-        const startPinnedWidth = layout.pinnedSystemColumnsWidth + layout.leftWidth;
-        const bodyLeft = contentLeft + (rtl ? layout.rightWidth : startPinnedWidth);
-        const bodyRight = contentLeft + width - (rtl ? startPinnedWidth : layout.rightWidth);
-
-        // The cell under the pointer; outside the viewport (or over the header) the nearest body cell.
-        const x = clamp(drag.x, contentLeft + 1, contentLeft + width - 2);
-        let y = drag.y;
-        if (y < box.top || y > box.top + height || y < headerBottom) y = clamp(y, bodyTop + 1, Math.max(bodyTop + 1, bodyBottom - 2));
-        // During fast auto-scroll the rows under the pointer may not be rendered yet (virtualization
-        // lags a frame or more on a slow machine), so the hit test finds no cell. Work out the row from
-        // the scroll position and the row heights instead, keeping the column the head already has.
-        const canHitTest = typeof viewport.ownerDocument.elementFromPoint === 'function';
-        const hit = headAtPoint(viewport, x, y);
-        const head = hit ?? (canHitTest && drag.moved
-            ? headRowAtOffset(y - box.top - (stickyTop ? stickyTop.getBoundingClientRect().height : 0) + viewport.scrollTop, drag.head.field)
-            : null);
-        if (head && !isSameCoordinates(head, drag.head)) {
-            drag.head = head;
-            if (!drag.moved && !isSameCoordinates(head, drag.anchor)) {
-                drag.moved = true;
-                latestRef.current.containerRef.current?.classList.add(RANGE_DRAGGING_CLASS);
-                clearTextSelection(viewport.ownerDocument);
-            }
-            if (!drag.onTrack) setRange(drag.anchor, head, 'pointer');
-        }
-        // A tracked drag hears every frame: its target can change while the head stays in one cell.
-        if (drag.onTrack) drag.onTrack(drag.head, drag.x, drag.y);
-
-        if (!allowScroll) return;
-        const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x < contentLeft || drag.x > contentLeft + width;
-        if (!drag.moved && !outside) return;
-        let dy = 0;
-        let dx = 0;
-        if (drag.y < bodyTop + AUTO_SCROLL_EDGE) dy = -autoScrollStep(bodyTop + AUTO_SCROLL_EDGE - drag.y);
-        else if (drag.y > bodyBottom - AUTO_SCROLL_EDGE) dy = autoScrollStep(drag.y - (bodyBottom - AUTO_SCROLL_EDGE));
-        if (drag.x < bodyLeft + AUTO_SCROLL_EDGE) dx = -autoScrollStep(bodyLeft + AUTO_SCROLL_EDGE - drag.x);
-        else if (drag.x > bodyRight - AUTO_SCROLL_EDGE) dx = autoScrollStep(drag.x - (bodyRight - AUTO_SCROLL_EDGE));
-        const canScrollY = (dy < 0 && viewport.scrollTop > 0) || (dy > 0 && viewport.scrollTop + height < viewport.scrollHeight - 1);
-        // scrollLeft runs from 0 to the maximum, or from minus the maximum to 0 right to left.
-        const maxScroll = viewport.scrollWidth - width;
-        const minLeft = rtl ? -maxScroll : 0;
-        const canScrollX = (dx < 0 && viewport.scrollLeft > minLeft) || (dx > 0 && viewport.scrollLeft < minLeft + maxScroll - 1);
-        if (!canScrollY && !canScrollX) return;
-        if (canScrollY) viewport.scrollTop += dy;
-        if (canScrollX) viewport.scrollLeft += dx;
-        // Keep going while the pointer rests in the edge zone; the next frame picks the new head.
-        drag.frame = requestAnimationFrame(() => runDragFrameRef.current(true));
-    }, [headAtPoint, headRowAtOffset, setRange]);
-    useLayoutEffect(() => {
-        runDragFrameRef.current = runDragFrame;
-    }, [runDragFrame]);
-
-    const startDrag = useCallback((
-        anchor: GridCellCoordinates,
-        head: GridCellCoordinates,
-        x: number,
-        y: number,
-        doc: Document,
-        tracked?: Pick,
-    ) => {
-        dragRef.current?.stop();
-        // A range drag follows the mouse; a tracked drag (fill handle) follows its pointer, touch included.
-        const moveType = tracked ? 'pointermove' : 'mousemove';
-        const upType = tracked ? 'pointerup' : 'mouseup';
-        const isOwnEvent = (event: MouseEvent) => !tracked || (event as PointerEvent).pointerId === tracked.pointerId;
-        const onMove = (event: MouseEvent) => {
-            const drag = dragRef.current;
-            if (!drag || !isOwnEvent(event)) return;
-            if (event.buttons === 0) {
-                // Released outside the window.
-                drag.stop();
-                return;
-            }
-            drag.x = event.clientX;
-            drag.y = event.clientY;
-            // The native text selection started by the press would otherwise follow the pointer out
-            // of the grid (WebKit), and a page text selection makes Ctrl/Cmd+C leave the copy to the browser.
-            if (drag.moved) {
-                event.preventDefault();
-                clearTextSelection(doc);
-            }
-            if (!drag.frame) drag.frame = requestAnimationFrame(() => runDragFrame(true));
-        };
-        const onUp = (event: MouseEvent) => {
-            const drag = dragRef.current;
-            if (!drag || !isOwnEvent(event)) return;
-            drag.x = event.clientX;
-            drag.y = event.clientY;
-            runDragFrame(false);
-            const moved = drag.moved;
-            drag.stop(false, event.altKey);
-            if (moved) clearTextSelection(doc);
-            if (moved) {
-                // The click that follows lands on the common ancestor of both cells (often the row):
-                // it must not select the row or move focus.
-                suppressClickRef.current = true;
-                setTimeout(() => { suppressClickRef.current = false; }, 0);
-            }
-        };
-        const onCancel = (event: Event) => {
-            if (isOwnEvent(event as MouseEvent)) dragRef.current?.stop();
-        };
-        // Escape abandons a tracked drag (the fill writes nothing).
-        const onKeyDown = (event: KeyboardEvent) => {
-            if (event.key !== 'Escape' || !dragRef.current) return;
-            event.preventDefault();
-            event.stopPropagation();
-            dragRef.current.stop();
-        };
-        const view = doc.defaultView;
-        const onBlur = () => dragRef.current?.stop();
-        let ended = false;
-        const drag: DragState = {
-            anchor, head, x, y, frame: 0, moved: false,
-            onTrack: tracked?.onTrack,
-            stop: (cancelled = true, altKey = false) => {
-                if (drag.frame) cancelAnimationFrame(drag.frame);
-                drag.frame = 0;
-                doc.removeEventListener(moveType, onMove, true);
-                doc.removeEventListener(upType, onUp, true);
-                if (tracked) {
-                    doc.removeEventListener('pointercancel', onCancel, true);
-                    doc.removeEventListener('keydown', onKeyDown, true);
-                }
-                view?.removeEventListener('blur', onBlur);
-                latestRef.current.containerRef.current?.classList.remove(RANGE_DRAGGING_CLASS);
-                if (dragRef.current === drag) dragRef.current = null;
-                if (!ended) {
-                    ended = true;
-                    if (tracked) tracked.onEnd({ cancelled, altKey });
-                }
-            },
-        };
-        dragRef.current = drag;
-        doc.addEventListener(moveType, onMove, true);
-        doc.addEventListener(upType, onUp, true);
-        if (tracked) {
-            doc.addEventListener('pointercancel', onCancel, true);
-            doc.addEventListener('keydown', onKeyDown, true);
-        }
-        view?.addEventListener('blur', onBlur);
-    }, [runDragFrame]);
-
-    const startTrackedDrag = useCallback((options: GridTrackedDragOptions) => {
-        const viewport = latestRef.current.viewportRef.current;
-        if (!viewport || !latestRef.current.enabled) return;
-        const { start, x, y, pointerId, onTrack, onEnd } = options;
-        startDrag(start, start, x, y, viewport.ownerDocument, { pointerId, onTrack, onEnd });
-    }, [startDrag]);
-
-    const handleMouseDown = useCallback((event: React.MouseEvent) => {
-        if (event.button !== 0 || event.defaultPrevented) return;
-        const { context: ctx } = latestRef.current;
-        if (!ctx) return;
-        const viewport = event.currentTarget;
-        const hit = cellFromTarget(event.target, viewport);
-        if (!hit || !isRangeColumnField(hit.field)) return;
-        // The fill handle starts its own drag (useGridFillHandle).
-        if ((event.target as Element).closest?.('.ogx__cell-fill-handle')) return;
-        // Typing targets and open editors keep the pointer to themselves.
-        if (hit.element.classList.contains('ogx__cell--editing') || isTextEntryElement(event.target as Element)) return;
-        const id = ctx.rowIds[hit.rowIndex];
-        if (id === undefined) return;
-        const cell = { id, field: hit.field };
-
-        let anchor: GridCellCoordinates = cell;
-        if (event.shiftKey) {
-            const current = currentModel()[0];
-            const focus = focusRef.current;
-            if (current && ctx.rowIndexById.has(current.anchor.id) && ctx.colIndexByField.has(current.anchor.field)) anchor = current.anchor;
-            else if (focus && focus.id !== null && isRangeColumnField(focus.field)) anchor = { id: focus.id, field: focus.field };
-            // Shift+click extends the range: no text selection, no focus move and no click (row
-            // selection, onCellClick) afterwards.
-            event.preventDefault();
-            suppressClickRef.current = true;
-            const doc = viewport.ownerDocument;
-            if (!viewport.contains(doc.activeElement)) viewport.focus({ preventScroll: true });
-        }
-        setRange(anchor, cell, 'pointer');
-        startDrag(anchor, cell, event.clientX, event.clientY, viewport.ownerDocument);
-        if (event.shiftKey) {
-            const drag = dragRef.current;
-            if (drag && !isSameCoordinates(anchor, cell)) drag.moved = true;
-        }
-    }, [currentModel, setRange, startDrag]);
-
-    const handleClickCapture = useCallback((event: React.MouseEvent) => {
-        if (!suppressClickRef.current) return;
-        suppressClickRef.current = false;
-        event.stopPropagation();
-        event.preventDefault();
-    }, []);
+    // ── Pointer: click, Shift+click and drag (useGridCellRangeDrag) ──────────────
+    const { startTrackedDrag, handleMouseDown, handleClickCapture } = useGridCellRangeDrag({ latestRef, focusRef, setRange, currentModel });
 
     // ── Live region and status bar ──────────────────────────────────────────
     const columnCount = resolved ? resolved.right - resolved.left + 1 : 0;

From 0fb49264ff9a30153e2e488a981084f19a749a6c Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 17:46:40 +0530
Subject: [PATCH 11/18] refactor(keyboard): move keydown handling into
 handleGridKeyDown

A plain function over a typed GridKeyDownContext, called from the hook's
useCallback with the same dependencies; first direct unit tests of the key
handling. useGridKeyboardNavigation.ts 627 -> 443 lines
---
 CLAUDE.md                                     |   1 +
 .../plans/2026-10-13-audit-tracker.md         |   5 +-
 lib/hooks/core/gridKeyDown.test.ts            |  73 +++++
 lib/hooks/core/gridKeyDown.ts                 | 272 +++++++++++++++++
 lib/hooks/core/useGridKeyboardNavigation.ts   | 284 +++---------------
 5 files changed, 399 insertions(+), 236 deletions(-)
 create mode 100644 lib/hooks/core/gridKeyDown.test.ts
 create mode 100644 lib/hooks/core/gridKeyDown.ts

diff --git a/CLAUDE.md b/CLAUDE.md
index 52cfa610..f07a9ad4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,6 +162,7 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs`
 - **`npm run check:package`** (after `build:lib`; in `ci.yml` and `npm-publish.yml`): fails if `npm pack` would ship a test artifact (`*.test.*`, `__baseline__`, `__screenshots__`, `__snapshots__`, `__fixtures__`) and if `DataGrid` alone, minified with esbuild and gzipped, exceeds `DATAGRID_GZIP_BUDGET` (92 KB; 85.5 KB at 3.9.0). `files` in package.json works by exclusion: add a negation for any new kind of test folder under `lib/`. Raise the budget only in the commit that needs it
 ## v3.10.0 — significant changes (unreleased)
 - R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
+- R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged); keydown handling in `handleGridKeyDown(event, context)` (`hooks/core/gridKeyDown.ts`, a plain function over a typed `GridKeyDownContext`, unit-testable without rendering; `useGridKeyboardNavigation` calls it from a `useCallback` with the same dependencies). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
 - Demo code samples go through `demo/components/CodeBlock.tsx` (prism-react-renderer, demo-only devDependency); live output panels (JSON state, logs) stay plain `
`
 
 ## v3.9.0 — significant changes
diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md
index 2f3b56c7..8f49fb98 100644
--- a/docs/superpowers/plans/2026-10-13-audit-tracker.md
+++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md
@@ -45,8 +45,8 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 | R2 | Shared filter-value helper for FilterPanel + HeaderFilterCell (6 valueOptions readers) | S–M | ✅ | `lib/utils/filtering/filterInput.ts` (4 helpers) used by FilterPanel + HeaderFilterCell |
 | R3 | Stop writing `_isSkeleton` onto rows | M | ✅ | `Row` `skeleton` prop; `_isSkeleton` removed from `GridRowModel` and editing/spanning |
 | R4 | Reduce ~41 `as unknown as` casts (internal column type) | L | ⬜ | |
-| R5 | Split files >600 lines (`GridToolbar.tsx` 855, …) | M each | ⬜ | |
-| R6 | Narrow wide hook interfaces (keyboard nav: 31 params) | L | ⬜ | |
+| R5 | Split files >600 lines (`GridToolbar.tsx` 855, …) | M each | ✅ | GridToolbar 861→492 (panels + `useToolbarPanel`), exporters one file per format, xlsx helpers → `excelCells`, range drag → `useGridCellRangeDrag`, keydown → `gridKeyDown.ts`. `DataGrid.tsx` (618) left: it is the documented hook order |
+| R6 | Narrow wide hook interfaces (keyboard nav: 31 params) | L | ✅ | Keyboard: the 225-line keydown body is `handleGridKeyDown(event, GridKeyDownContext)`, a typed, unit-tested function; regrouping the 31 params into objects was rejected (inline objects would break memoised callbacks) |
 | R7 | Route ~70 hard-coded English strings through `localeText` | M | ✅ | 196 keys (more than the ~70 estimated: pivot/toolbar/AI status included), `GridLocaleContext`, `useShallowStableValue`. Not covered: AI chip texts, grouping column default header, file/row contents |
 | R8 | Document 24 exports missing from `docs/API_REFERENCE.md` | S | ✅ | All 23 missing exports documented in `API_REFERENCE.md` |
 
@@ -74,3 +74,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 - 2026-10-13 — AI change chips translatable (11 keys, branch `release/3.10.0`). P9 measured and deferred to a 4.0 feature-modules proposal (user agreed).
 - 2026-10-13 — Docs sweep (roadmap, performance, README, wiki home, rtl, pagination, architecture). Found and fixed: npm package shipped `lib/theme/__baseline__` (1.6 MB). Feature hold recorded (user).
 - 2026-10-13 — Backlog 1/6: demo syntax highlighting done. Insight follow-ups: `check:package` (no test artifacts in the tarball + DataGrid-only 92 KB gz budget) in CI and publish.
+- 2026-10-13 — R5 and R6 done (all engines green, compiler baseline). Next: R4 casts.
diff --git a/lib/hooks/core/gridKeyDown.test.ts b/lib/hooks/core/gridKeyDown.test.ts
new file mode 100644
index 00000000..3ae5c720
--- /dev/null
+++ b/lib/hooks/core/gridKeyDown.test.ts
@@ -0,0 +1,73 @@
+import { describe, it, expect, vi } from 'vitest';
+import type React from 'react';
+import { handleGridKeyDown, type GridKeyDownContext } from './gridKeyDown';
+import type { FocusedCell } from '../../utils/focus';
+
+// handleGridKeyDown is a plain function over an explicit context (R6, 3.10), so key handling can be
+// tested without rendering a grid.
+
+type Row = { id: number; name: string };
+const rows: Row[] = [{ id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 3, name: 'c' }];
+
+function context(overrides: Partial> = {}): GridKeyDownContext {
+    const focusedCell: FocusedCell = { id: 1, field: 'name' };
+    return {
+        allRenderableRows: rows,
+        navigationColumns: [{ field: 'name' }],
+        editingHandlers: { editingCell: null, startCellEdit: vi.fn(), stopCellEdit: vi.fn() },
+        selectedRowIds: new Set(),
+        handleSelectionChange: vi.fn(),
+        handleDetailPanelToggle: vi.fn(),
+        setKeyboardMode: vi.fn(),
+        sortModel: [],
+        handleSort: vi.fn(),
+        pagination: false,
+        pageSize: 25,
+        multiSort: false,
+        rowSelectionEnabled: true,
+        multipleRowSelectionEnabled: true,
+        headerFilters: false,
+        getRowId: r => r.id,
+        focusedCell,
+        focusedRowIndex: 0,
+        getDefaultFocus: () => focusedCell,
+        scrollCellIntoView: vi.fn(),
+        setFocusedCell: vi.fn(),
+        ...overrides,
+    } as GridKeyDownContext;
+}
+
+function keyEvent(key: string, init: Partial = {}): React.KeyboardEvent {
+    const viewport = document.createElement('div');
+    viewport.setAttribute('role', 'grid');
+    document.body.appendChild(viewport);
+    return {
+        key, target: viewport, currentTarget: viewport, keyCode: 0, shiftKey: false, ctrlKey: false, metaKey: false, altKey: false,
+        nativeEvent: { isComposing: false } as KeyboardEvent,
+        preventDefault: vi.fn(), stopPropagation: vi.fn(),
+        ...init,
+    } as unknown as React.KeyboardEvent;
+}
+
+describe('handleGridKeyDown', () => {
+    it('ArrowDown moves focus to the next row and scrolls it into view', () => {
+        const ctx = context();
+        const event = keyEvent('ArrowDown');
+        handleGridKeyDown(event, ctx);
+        expect(ctx.setFocusedCell).toHaveBeenCalledWith({ id: 2, field: 'name', rowIndex: 1 });
+        expect(ctx.scrollCellIntoView).toHaveBeenCalledWith(1, 'name');
+        expect(event.preventDefault).toHaveBeenCalled();
+    });
+
+    it('ArrowUp on the first row moves to the column header', () => {
+        const ctx = context();
+        handleGridKeyDown(keyEvent('ArrowUp'), ctx);
+        expect(ctx.setFocusedCell).toHaveBeenCalledWith({ id: null, field: 'name' });
+    });
+
+    it('ignores keys while an IME is composing', () => {
+        const ctx = context();
+        handleGridKeyDown(keyEvent('ArrowDown', { nativeEvent: { isComposing: true } as KeyboardEvent }), ctx);
+        expect(ctx.setFocusedCell).not.toHaveBeenCalled();
+    });
+});
diff --git a/lib/hooks/core/gridKeyDown.ts b/lib/hooks/core/gridKeyDown.ts
new file mode 100644
index 00000000..0b8ce2be
--- /dev/null
+++ b/lib/hooks/core/gridKeyDown.ts
@@ -0,0 +1,272 @@
+import { isRtl, logicalArrowKey } from '../../utils/direction';
+import { resolveCellEditable } from '../../utils/editing';
+import { getCellValue } from '../../utils/values';
+import { isSyntheticRowId } from '../../utils/syntheticRows';
+import {
+    CHECKBOX_FIELD,
+    EXPAND_FIELD,
+    REORDER_FIELD,
+    classifyKeyTarget,
+    findFocusTarget,
+    isSystemField,
+} from '../../utils/focus';
+import type { FocusedCell } from '../../utils/focus';
+import { isRangeColumnField } from '../../utils/cellSelection';
+import type {
+    GridRowModel,
+    GridRowId,
+    GridColDef,
+    GridSortDirection,
+} from '../../types';
+import type { UseGridKeyboardNavigationParams } from './useGridKeyboardNavigation';
+
+// The grid's keydown handling (arrows, Home / End, PageUp / PageDown, Enter, Escape, Space, Tab,
+// Ctrl/Cmd+A, sort and column-menu keys, header filter row, cell range keys). A plain function over an
+// explicit context: useGridKeyboardNavigation builds the context from its inputs and calls it, so the
+// handler's identity and dependencies are unchanged (R6, 3.10).
+
+/** Everything the keydown handling reads: the hook's inputs (defaults applied) and its focus state. */
+export interface GridKeyDownContext extends Pick,
+    'allRenderableRows' | 'navigationColumns' | 'editingHandlers' | 'selectedRowIds' | 'handleSelectionChange' | 'handleDetailPanelToggle' | 'setKeyboardMode' | 'sortModel' | 'handleSort' | 'handleSortAdd' | 'isCellEditable' | 'pagination' | 'pageSize' | 'rowMetaMap' | 'onRowActivate' | 'toggleRowExpansion' | 'handleSelectAll' | 'getSpanOrigin' | 'cellSelection'> {
+    multiSort: boolean;
+    rowSelectionEnabled: boolean;
+    multipleRowSelectionEnabled: boolean;
+    headerFilters: boolean;
+    getRowId: (row: R) => GridRowId;
+    focusedCell: FocusedCell | null;
+    focusedRowIndex: number;
+    getDefaultFocus: () => FocusedCell | null;
+    scrollCellIntoView: (rowIndex: number, field: string) => void;
+    setFocusedCell: React.Dispatch>;
+}
+
+/** Keys that move focus between cells. */
+export const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']);
+
+export function handleGridKeyDown(event: React.KeyboardEvent, context: GridKeyDownContext): void {
+    const { focusedCell, focusedRowIndex, allRenderableRows, navigationColumns, editingHandlers, selectedRowIds, handleSelectionChange, handleDetailPanelToggle, setKeyboardMode, sortModel, handleSort, handleSortAdd, multiSort, isCellEditable, pagination, pageSize, rowMetaMap, onRowActivate, toggleRowExpansion, handleSelectAll, rowSelectionEnabled, multipleRowSelectionEnabled, getDefaultFocus, scrollCellIntoView, getSpanOrigin, getRowId, cellSelection, headerFilters, setFocusedCell } = context;
+        const viewport = event.currentTarget as HTMLElement;
+        const targetKind = classifyKeyTarget(event.target, viewport);
+        if (targetKind === 'foreign') return;
+        setKeyboardMode(true);
+
+        // While an IME is composing, Enter / Escape / arrows confirm or navigate candidates.
+        if (event.nativeEvent?.isComposing || event.keyCode === 229) return;
+
+        // In a right-to-left grid ArrowLeft moves to the column on the left: the next column.
+        const key = logicalArrowKey(event.key, isRtl(viewport));
+        const isSpace = key === ' ' || key === 'Spacebar';
+        const isEditing = Boolean(editingHandlers.editingCell);
+
+        // Row positions: rows from 0, the column header row at `headerRow`, and the header filter
+        // row (when rendered) at -1, between the header and the rows.
+        const headerRow = headerFilters ? -2 : -1;
+        const moveTo = (r: number, c: number) => {
+            const col = navigationColumns[c];
+            if (!col || r < headerRow || r >= allRenderableRows.length) return;
+            if (r === headerRow) setFocusedCell({ id: null, field: col.field });
+            else if (r < 0) setFocusedCell({ id: null, field: col.field, headerFilter: true });
+            else setFocusedCell({ id: getRowId(allRenderableRows[r]), field: col.field, rowIndex: r });
+            scrollCellIntoView(r, col.field);
+        };
+
+        if (!focusedCell) {
+            if (!NAVIGATION_KEYS.has(key)) return;
+            const initial = getDefaultFocus();
+            if (!initial) return;
+            event.preventDefault();
+            setFocusedCell(initial);
+            scrollCellIntoView(initial.rowIndex ?? -1, initial.field);
+            return;
+        }
+
+        const { id, field } = focusedCell;
+        const onHeaderFilter = id === null && Boolean(focusedCell.headerFilter);
+        const rowIndex = id !== null ? focusedRowIndex : onHeaderFilter ? -1 : headerRow;
+        const colIndex = navigationColumns.findIndex(c => c.field === field);
+        const systemColumnCount = navigationColumns.filter(c => isSystemField(c.field)).length;
+        const isSyntheticRow = (rowId: GridRowId) => isSyntheticRowId(rowId, rowMetaMap);
+        // Same rule and starting value as double-click (see Row / resolveCellEditable).
+        const isEditableCell = (row: R, r: number, c: number) => {
+            const colDef = navigationColumns[c] as GridColDef;
+            if (isSystemField(colDef.field)) return false;
+            return resolveCellEditable({
+                row,
+                field: colDef.field,
+                value: getCellValue(row, colDef.field, colDef),
+                colDef,
+                rowIndex: r,
+                colIndex: c - systemColumnCount,
+                rowMeta: rowMetaMap?.get(getRowId(row)),
+            }, isCellEditable);
+        };
+
+        // Next editable data cell in reading order, for Tab while editing.
+        const findNextEditable = (deltaCol: number) => {
+            let r = rowIndex;
+            let c = colIndex;
+            const maxSteps = (allRenderableRows.length + 1) * navigationColumns.length;
+            for (let steps = 0; steps < maxSteps; steps++) {
+                c += deltaCol;
+                if (c >= navigationColumns.length) { c = 0; r++; }
+                else if (c < 0) { c = navigationColumns.length - 1; r--; }
+                if (r < 0 || r >= allRenderableRows.length) return null;
+
+                const row = allRenderableRows[r];
+                // Cells covered by a span are not stops; their origin is.
+                if (getSpanOrigin?.(getRowId(row), navigationColumns[c].field)) continue;
+                if (isEditableCell(row, r, c)) return { r, c };
+            }
+            return null;
+        };
+
+        if (isEditing) {
+            if (key === 'Enter') {
+                event.preventDefault();
+                editingHandlers.stopCellEdit();
+            } else if (key === 'Escape') {
+                event.preventDefault();
+                editingHandlers.stopCellEdit({ cancel: true });
+            } else if (key === 'Tab' && id !== null) {
+                // No next editable cell: the browser moves focus on and the editor commits on blur.
+                const next = findNextEditable(event.shiftKey ? -1 : 1);
+                if (next) {
+                    event.preventDefault();
+                    editingHandlers.stopCellEdit();
+                    moveTo(next.r, next.c);
+                }
+            }
+            return;
+        }
+
+        // Tab is not captured outside edit mode: the grid is a single tab stop and Tab leaves it
+        // (or reaches focusable content inside cells and detail panels).
+        if (key === 'Escape' && cellSelection?.collapseSelection()) {
+            event.preventDefault();
+            return;
+        }
+        if (key === 'Tab' || key === 'Escape') return;
+        // Enter and Space activate a control rendered inside a cell.
+        if (targetKind === 'control' && (key === 'Enter' || isSpace)) return;
+
+        if ((event.ctrlKey || event.metaKey) && (key === 'a' || key === 'A')) {
+            if (cellSelection) {
+                if (cellSelection.selectAllCells()) event.preventDefault();
+                return;
+            }
+            if (!rowSelectionEnabled || !multipleRowSelectionEnabled || !handleSelectAll) return;
+            event.preventDefault();
+            handleSelectAll(true);
+            return;
+        }
+
+        // A filter cell handles Enter, Space and typing itself (HeaderFilterCell).
+        if (onHeaderFilter && (key === 'Enter' || isSpace)) return;
+        if (id === null) {
+            if (key === 'Enter' || isSpace) {
+                event.preventDefault();
+                if (field === CHECKBOX_FIELD) {
+                    // Same as clicking the select-all checkbox.
+                    findFocusTarget(viewport, focusedCell, -1)?.click();
+                    return;
+                }
+                const col = navigationColumns[colIndex];
+                if (!col || isSystemField(field) || col.sortable === false) return;
+                const currentSort = sortModel.find(item => item.field === field);
+                const direction: GridSortDirection = currentSort ? (currentSort.sort === 'asc' ? 'desc' : null) : 'asc';
+                if ((multiSort || event.shiftKey) && handleSortAdd) handleSortAdd(field, direction);
+                else handleSort(field, direction);
+                return;
+            }
+        } else if (key === 'Enter' || isSpace) {
+            event.preventDefault();
+            const row = allRenderableRows[rowIndex];
+            if (field === CHECKBOX_FIELD) {
+                handleSelectionChange(id, !selectedRowIds.has(id));
+                return;
+            }
+            if (field === EXPAND_FIELD) {
+                if (!event.shiftKey) handleDetailPanelToggle(id);
+                return;
+            }
+            if (isSpace) {
+                // Shift+Space selects the row; plain Space only must not scroll the viewport.
+                if (event.shiftKey && rowSelectionEnabled && !isSyntheticRow(id)) {
+                    handleSelectionChange(id, !selectedRowIds.has(id));
+                }
+                return;
+            }
+            if (field === REORDER_FIELD) return;
+            if (isEditableCell(row, rowIndex, colIndex)) {
+                editingHandlers.startCellEdit({ id, field, value: getCellValue(row, field, navigationColumns[colIndex] as GridColDef) });
+                return;
+            }
+            // A row with children toggles, so keyboard users can expand a tree-data parent even
+            // though clicking it selects it. Others: the keyboard equivalent of clicking the row.
+            if (toggleRowExpansion && rowMetaMap?.get(id)?.hasChildren) {
+                toggleRowExpansion(id);
+                return;
+            }
+            onRowActivate?.({ row, id, rowIndex });
+            return;
+        }
+
+        // Treegrid shortcuts: Alt+ArrowRight expands, Alt+ArrowLeft collapses the focused row.
+        if (event.altKey && (key === 'ArrowRight' || key === 'ArrowLeft') && toggleRowExpansion && id !== null) {
+            event.preventDefault();
+            const meta = rowMetaMap?.get(id);
+            if (meta?.hasChildren && Boolean(meta.isExpanded) !== (key === 'ArrowRight')) toggleRowExpansion(id);
+            return;
+        }
+
+        if (!NAVIGATION_KEYS.has(key)) return;
+        // Prevent native scrolling even when the focus cannot move (grid edges).
+        event.preventDefault();
+
+        const lastRow = allRenderableRows.length - 1;
+        const lastCol = navigationColumns.length - 1;
+        const withModifier = event.ctrlKey || event.metaKey;
+        const page = pagination ? pageSize : 10;
+
+        // Shift+navigation on a data cell extends the cell range; focus stays on its anchor.
+        if (cellSelection && event.shiftKey && id !== null && isRangeColumnField(field)
+            && cellSelection.extendSelection(key, withModifier, page, focusedCell)) {
+            return;
+        }
+
+        const step = (r: number, c: number): { r: number; c: number } => {
+            switch (key) {
+                case 'ArrowRight': return c + 1 > lastCol ? { r: r + 1, c: 0 } : { r, c: c + 1 };
+                case 'ArrowLeft': return c - 1 < 0 ? { r: r - 1, c: lastCol } : { r, c: c - 1 };
+                case 'ArrowDown': return { r: r + 1, c };
+                case 'ArrowUp': return { r: r - 1, c };
+                case 'Home': return { r: withModifier ? headerRow : r, c: 0 };
+                case 'End': return { r: withModifier ? lastRow : r, c: lastCol };
+                case 'PageUp': return { r: Math.max(headerRow, r - page), c };
+                default: return { r: Math.min(lastRow, r + page), c };
+            }
+        };
+        const inGrid = ({ r, c }: { r: number; c: number }) => r >= headerRow && r <= lastRow && c >= 0 && c <= lastCol;
+        const spanOriginAt = ({ r, c }: { r: number; c: number }) =>
+            r >= 0 ? getSpanOrigin?.(getRowId(allRenderableRows[r]), navigationColumns[c].field) ?? null : null;
+
+        let next = step(rowIndex, colIndex);
+        // Arrow keys step over the cells of the focused span (its own merged area) ...
+        if (key.startsWith('Arrow')) {
+            for (let origin = inGrid(next) ? spanOriginAt(next) : null;
+                origin && origin.rowId === id && origin.field === field;
+                origin = inGrid(next) ? spanOriginAt(next) : null) {
+                next = step(next.r, next.c);
+            }
+        }
+        if (!inGrid(next)) return;
+        // ... then land on the origin of whatever span covers the target cell.
+        const origin = spanOriginAt(next);
+        if (origin) {
+            const originRow = allRenderableRows.findIndex(r => getRowId(r) === origin.rowId);
+            const originCol = navigationColumns.findIndex(c => c.field === origin.field);
+            if (originRow !== -1 && originCol !== -1) next = { r: originRow, c: originCol };
+        }
+        if (next.r === rowIndex && next.c === colIndex) return;
+        moveTo(next.r, next.c);
+}
diff --git a/lib/hooks/core/useGridKeyboardNavigation.ts b/lib/hooks/core/useGridKeyboardNavigation.ts
index e48bc751..e09aa170 100644
--- a/lib/hooks/core/useGridKeyboardNavigation.ts
+++ b/lib/hooks/core/useGridKeyboardNavigation.ts
@@ -1,22 +1,33 @@
-import { useState, useCallback, useMemo, useRef, useLayoutEffect } from 'react';
-import { getScrollStart, isRtl, logicalArrowKey, setScrollStart } from '../../utils/direction';
+import {
+    useState,
+    useCallback,
+    useMemo,
+    useRef,
+    useLayoutEffect,
+} from 'react';
+import { handleGridKeyDown } from './gridKeyDown';
+import { getScrollStart, setScrollStart } from '../../utils/direction';
 import { scrollRowIntoView } from '../../utils/scroll';
-import { resolveCellEditable } from '../../utils/editing';
-import { getCellValue } from '../../utils/values';
-import { isSyntheticRowId } from '../../utils/syntheticRows';
 import {
     CHECKBOX_FIELD,
     EXPAND_FIELD,
     REORDER_FIELD,
-    classifyKeyTarget,
     findFocusTarget,
     focusedCellFromElement,
     isSameCell,
     isSystemField,
 } from '../../utils/focus';
 import type { FocusedCell } from '../../utils/focus';
-import { isRangeColumnField } from '../../utils/cellSelection';
-import type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection, GridRowParams, GridRowMeta } from '../../types';
+import type {
+    GridRowModel,
+    GridRowId,
+    GridColDef,
+    GridCellParams,
+    GridSortItem,
+    GridSortDirection,
+    GridRowParams,
+    GridRowMeta,
+} from '../../types';
 import type { GridEditingState } from '../features/useGridEditing';
 
 export type { FocusedCell } from '../../utils/focus';
@@ -133,8 +144,6 @@ export interface UseGridKeyboardNavigationReturn {
 
 const defaultGetRowId = (row: R): GridRowId => row.id;
 
-const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']);
-
 /** Keeps a stored focus position valid when its row or column is no longer rendered. */
 function resolveFocus(
     cell: FocusedCell | null,
@@ -359,230 +368,37 @@ export function useGridKeyboardNavigation(
     }, [setKeyboardMode]);
 
     const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
-        const viewport = event.currentTarget as HTMLElement;
-        const targetKind = classifyKeyTarget(event.target, viewport);
-        if (targetKind === 'foreign') return;
-        setKeyboardMode(true);
-
-        // While an IME is composing, Enter / Escape / arrows confirm or navigate candidates.
-        if (event.nativeEvent?.isComposing || event.keyCode === 229) return;
-
-        // In a right-to-left grid ArrowLeft moves to the column on the left: the next column.
-        const key = logicalArrowKey(event.key, isRtl(viewport));
-        const isSpace = key === ' ' || key === 'Spacebar';
-        const isEditing = Boolean(editingHandlers.editingCell);
-
-        // Row positions: rows from 0, the column header row at `headerRow`, and the header filter
-        // row (when rendered) at -1, between the header and the rows.
-        const headerRow = headerFilters ? -2 : -1;
-        const moveTo = (r: number, c: number) => {
-            const col = navigationColumns[c];
-            if (!col || r < headerRow || r >= allRenderableRows.length) return;
-            if (r === headerRow) setFocusedCell({ id: null, field: col.field });
-            else if (r < 0) setFocusedCell({ id: null, field: col.field, headerFilter: true });
-            else setFocusedCell({ id: getRowId(allRenderableRows[r]), field: col.field, rowIndex: r });
-            scrollCellIntoView(r, col.field);
-        };
-
-        if (!focusedCell) {
-            if (!NAVIGATION_KEYS.has(key)) return;
-            const initial = getDefaultFocus();
-            if (!initial) return;
-            event.preventDefault();
-            setFocusedCell(initial);
-            scrollCellIntoView(initial.rowIndex ?? -1, initial.field);
-            return;
-        }
-
-        const { id, field } = focusedCell;
-        const onHeaderFilter = id === null && Boolean(focusedCell.headerFilter);
-        const rowIndex = id !== null ? focusedRowIndex : onHeaderFilter ? -1 : headerRow;
-        const colIndex = navigationColumns.findIndex(c => c.field === field);
-        const systemColumnCount = navigationColumns.filter(c => isSystemField(c.field)).length;
-        const isSyntheticRow = (rowId: GridRowId) => isSyntheticRowId(rowId, rowMetaMap);
-        // Same rule and starting value as double-click (see Row / resolveCellEditable).
-        const isEditableCell = (row: R, r: number, c: number) => {
-            const colDef = navigationColumns[c] as GridColDef;
-            if (isSystemField(colDef.field)) return false;
-            return resolveCellEditable({
-                row,
-                field: colDef.field,
-                value: getCellValue(row, colDef.field, colDef),
-                colDef,
-                rowIndex: r,
-                colIndex: c - systemColumnCount,
-                rowMeta: rowMetaMap?.get(getRowId(row)),
-            }, isCellEditable);
-        };
-
-        // Next editable data cell in reading order, for Tab while editing.
-        const findNextEditable = (deltaCol: number) => {
-            let r = rowIndex;
-            let c = colIndex;
-            const maxSteps = (allRenderableRows.length + 1) * navigationColumns.length;
-            for (let steps = 0; steps < maxSteps; steps++) {
-                c += deltaCol;
-                if (c >= navigationColumns.length) { c = 0; r++; }
-                else if (c < 0) { c = navigationColumns.length - 1; r--; }
-                if (r < 0 || r >= allRenderableRows.length) return null;
-
-                const row = allRenderableRows[r];
-                // Cells covered by a span are not stops; their origin is.
-                if (getSpanOrigin?.(getRowId(row), navigationColumns[c].field)) continue;
-                if (isEditableCell(row, r, c)) return { r, c };
-            }
-            return null;
-        };
-
-        if (isEditing) {
-            if (key === 'Enter') {
-                event.preventDefault();
-                editingHandlers.stopCellEdit();
-            } else if (key === 'Escape') {
-                event.preventDefault();
-                editingHandlers.stopCellEdit({ cancel: true });
-            } else if (key === 'Tab' && id !== null) {
-                // No next editable cell: the browser moves focus on and the editor commits on blur.
-                const next = findNextEditable(event.shiftKey ? -1 : 1);
-                if (next) {
-                    event.preventDefault();
-                    editingHandlers.stopCellEdit();
-                    moveTo(next.r, next.c);
-                }
-            }
-            return;
-        }
-
-        // Tab is not captured outside edit mode: the grid is a single tab stop and Tab leaves it
-        // (or reaches focusable content inside cells and detail panels).
-        if (key === 'Escape' && cellSelection?.collapseSelection()) {
-            event.preventDefault();
-            return;
-        }
-        if (key === 'Tab' || key === 'Escape') return;
-        // Enter and Space activate a control rendered inside a cell.
-        if (targetKind === 'control' && (key === 'Enter' || isSpace)) return;
-
-        if ((event.ctrlKey || event.metaKey) && (key === 'a' || key === 'A')) {
-            if (cellSelection) {
-                if (cellSelection.selectAllCells()) event.preventDefault();
-                return;
-            }
-            if (!rowSelectionEnabled || !multipleRowSelectionEnabled || !handleSelectAll) return;
-            event.preventDefault();
-            handleSelectAll(true);
-            return;
-        }
-
-        // A filter cell handles Enter, Space and typing itself (HeaderFilterCell).
-        if (onHeaderFilter && (key === 'Enter' || isSpace)) return;
-        if (id === null) {
-            if (key === 'Enter' || isSpace) {
-                event.preventDefault();
-                if (field === CHECKBOX_FIELD) {
-                    // Same as clicking the select-all checkbox.
-                    findFocusTarget(viewport, focusedCell, -1)?.click();
-                    return;
-                }
-                const col = navigationColumns[colIndex];
-                if (!col || isSystemField(field) || col.sortable === false) return;
-                const currentSort = sortModel.find(item => item.field === field);
-                const direction: GridSortDirection = currentSort ? (currentSort.sort === 'asc' ? 'desc' : null) : 'asc';
-                if ((multiSort || event.shiftKey) && handleSortAdd) handleSortAdd(field, direction);
-                else handleSort(field, direction);
-                return;
-            }
-        } else if (key === 'Enter' || isSpace) {
-            event.preventDefault();
-            const row = allRenderableRows[rowIndex];
-            if (field === CHECKBOX_FIELD) {
-                handleSelectionChange(id, !selectedRowIds.has(id));
-                return;
-            }
-            if (field === EXPAND_FIELD) {
-                if (!event.shiftKey) handleDetailPanelToggle(id);
-                return;
-            }
-            if (isSpace) {
-                // Shift+Space selects the row; plain Space only must not scroll the viewport.
-                if (event.shiftKey && rowSelectionEnabled && !isSyntheticRow(id)) {
-                    handleSelectionChange(id, !selectedRowIds.has(id));
-                }
-                return;
-            }
-            if (field === REORDER_FIELD) return;
-            if (isEditableCell(row, rowIndex, colIndex)) {
-                editingHandlers.startCellEdit({ id, field, value: getCellValue(row, field, navigationColumns[colIndex] as GridColDef) });
-                return;
-            }
-            // A row with children toggles, so keyboard users can expand a tree-data parent even
-            // though clicking it selects it. Others: the keyboard equivalent of clicking the row.
-            if (toggleRowExpansion && rowMetaMap?.get(id)?.hasChildren) {
-                toggleRowExpansion(id);
-                return;
-            }
-            onRowActivate?.({ row, id, rowIndex });
-            return;
-        }
-
-        // Treegrid shortcuts: Alt+ArrowRight expands, Alt+ArrowLeft collapses the focused row.
-        if (event.altKey && (key === 'ArrowRight' || key === 'ArrowLeft') && toggleRowExpansion && id !== null) {
-            event.preventDefault();
-            const meta = rowMetaMap?.get(id);
-            if (meta?.hasChildren && Boolean(meta.isExpanded) !== (key === 'ArrowRight')) toggleRowExpansion(id);
-            return;
-        }
-
-        if (!NAVIGATION_KEYS.has(key)) return;
-        // Prevent native scrolling even when the focus cannot move (grid edges).
-        event.preventDefault();
-
-        const lastRow = allRenderableRows.length - 1;
-        const lastCol = navigationColumns.length - 1;
-        const withModifier = event.ctrlKey || event.metaKey;
-        const page = pagination ? pageSize : 10;
-
-        // Shift+navigation on a data cell extends the cell range; focus stays on its anchor.
-        if (cellSelection && event.shiftKey && id !== null && isRangeColumnField(field)
-            && cellSelection.extendSelection(key, withModifier, page, focusedCell)) {
-            return;
-        }
-
-        const step = (r: number, c: number): { r: number; c: number } => {
-            switch (key) {
-                case 'ArrowRight': return c + 1 > lastCol ? { r: r + 1, c: 0 } : { r, c: c + 1 };
-                case 'ArrowLeft': return c - 1 < 0 ? { r: r - 1, c: lastCol } : { r, c: c - 1 };
-                case 'ArrowDown': return { r: r + 1, c };
-                case 'ArrowUp': return { r: r - 1, c };
-                case 'Home': return { r: withModifier ? headerRow : r, c: 0 };
-                case 'End': return { r: withModifier ? lastRow : r, c: lastCol };
-                case 'PageUp': return { r: Math.max(headerRow, r - page), c };
-                default: return { r: Math.min(lastRow, r + page), c };
-            }
-        };
-        const inGrid = ({ r, c }: { r: number; c: number }) => r >= headerRow && r <= lastRow && c >= 0 && c <= lastCol;
-        const spanOriginAt = ({ r, c }: { r: number; c: number }) =>
-            r >= 0 ? getSpanOrigin?.(getRowId(allRenderableRows[r]), navigationColumns[c].field) ?? null : null;
-
-        let next = step(rowIndex, colIndex);
-        // Arrow keys step over the cells of the focused span (its own merged area) ...
-        if (key.startsWith('Arrow')) {
-            for (let origin = inGrid(next) ? spanOriginAt(next) : null;
-                origin && origin.rowId === id && origin.field === field;
-                origin = inGrid(next) ? spanOriginAt(next) : null) {
-                next = step(next.r, next.c);
-            }
-        }
-        if (!inGrid(next)) return;
-        // ... then land on the origin of whatever span covers the target cell.
-        const origin = spanOriginAt(next);
-        if (origin) {
-            const originRow = allRenderableRows.findIndex(r => getRowId(r) === origin.rowId);
-            const originCol = navigationColumns.findIndex(c => c.field === origin.field);
-            if (originRow !== -1 && originCol !== -1) next = { r: originRow, c: originCol };
-        }
-        if (next.r === rowIndex && next.c === colIndex) return;
-        moveTo(next.r, next.c);
+        handleGridKeyDown(event, {
+            focusedCell,
+            focusedRowIndex,
+            allRenderableRows,
+            navigationColumns,
+            editingHandlers,
+            selectedRowIds,
+            handleSelectionChange,
+            handleDetailPanelToggle,
+            setKeyboardMode,
+            sortModel,
+            handleSort,
+            handleSortAdd,
+            multiSort,
+            isCellEditable,
+            pagination,
+            pageSize,
+            rowMetaMap,
+            onRowActivate,
+            toggleRowExpansion,
+            handleSelectAll,
+            rowSelectionEnabled,
+            multipleRowSelectionEnabled,
+            getDefaultFocus,
+            scrollCellIntoView,
+            getSpanOrigin,
+            getRowId,
+            cellSelection,
+            headerFilters,
+            setFocusedCell,
+        });
     }, [
         focusedCell,
         focusedRowIndex,

From df226da074acd9ca632f81a2403e32adf6fb273a Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 17:55:53 +0530
Subject: [PATCH 12/18] refactor(types): remove 28 as-unknown-as casts

- Call the row pipeline, data source and row grouping with the grid's row
  type; type the store's setRows / replaceRows / rowsLookup with R
- untypedColumn(s) is the one place a GridColDef widens to the untyped
  public surfaces; narrowing is a plain as
- Row's system column definitions are module constants
---
 CHANGELOG.md                                  |  2 +
 CLAUDE.md                                     |  1 +
 .../plans/2026-10-13-audit-tracker.md         |  3 +-
 lib/components/Cell/Cell.tsx                  |  3 +-
 lib/components/DataGrid/DataGrid.tsx          | 37 ++++++++++---------
 lib/components/DataGrid/GridViewport.tsx      |  3 +-
 lib/components/Header/Header.tsx              |  7 ++--
 lib/components/Row/Row.tsx                    |  8 +++-
 lib/hooks/core/useDataGrid.ts                 | 18 ++++++---
 lib/hooks/core/useGridColumnLookup.ts         |  3 +-
 lib/hooks/core/useGridColumns.tsx             |  5 ++-
 lib/hooks/core/useGridHierarchy.ts            |  2 +-
 lib/hooks/core/useGridToolbarProps.ts         |  5 ++-
 lib/hooks/useColumnReorder.ts                 |  3 +-
 lib/hooks/useTreeData.ts                      |  6 ++-
 lib/utils/untypedColumns.ts                   | 16 ++++++++
 16 files changed, 82 insertions(+), 40 deletions(-)
 create mode 100644 lib/utils/untypedColumns.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddb2a7b2..452707f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,8 @@
 
 ### Internal
 
+- **Refactors (no behaviour change):** `GridToolbar` split into its panels with a shared `useToolbarPanel` (861 → 492 lines); the exporters one file per format; the xlsx cell helpers in `excelCells`; the cell range drag in `useGridCellRangeDrag`; keydown handling in a plain, unit-tested `handleGridKeyDown` function. Type casts `as unknown as` 43 → 15: the row pipeline, data source and grouping are called with the row type, the row store is typed with it, and column widenings go through one documented helper. DOM, styles and behaviour unchanged (theme baseline and the full browser suite in three engines).
+
 - **`npm run check:package`**, run in CI and before publishing: fails when `npm pack` would ship a test artifact (the gap that let the theme baselines through) or when `DataGrid` alone, minified and gzipped, grows past its budget (92 KB; 85.5 KB now).
 - Demo: the source viewer on every example page is syntax-highlighted (`CodeBlock`, prism-react-renderer), and the integration tips under it are corrected (inline columns and handlers are fine since 3.0 / 3.9; replace rows instead of mutating them).
 
diff --git a/CLAUDE.md b/CLAUDE.md
index f07a9ad4..02d06549 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,6 +162,7 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs`
 - **`npm run check:package`** (after `build:lib`; in `ci.yml` and `npm-publish.yml`): fails if `npm pack` would ship a test artifact (`*.test.*`, `__baseline__`, `__screenshots__`, `__snapshots__`, `__fixtures__`) and if `DataGrid` alone, minified with esbuild and gzipped, exceeds `DATAGRID_GZIP_BUDGET` (92 KB; 85.5 KB at 3.9.0). `files` in package.json works by exclusion: add a negation for any new kind of test folder under `lib/`. Raise the budget only in the commit that needs it
 ## v3.10.0 — significant changes (unreleased)
 - R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
+- **Types (R4)**: a `GridColDef` passed to an untyped public surface (callback params, toolbar, store) goes through `untypedColumn` / `untypedColumns` (`lib/utils/untypedColumns.ts`), the one place that widening is written; narrowing untyped → `GridColDef` / `R[]` is a plain `as`. `useDataGrid` types `setRows` / `replaceRows` / `rowsLookup` with `R`; call generic hooks with `` explicitly when an untyped argument would otherwise infer `GridRowModel`. Remaining `as unknown as` (15) are synthetic rows, third-party module typings, `window` / CSS custom properties: do not add new ones elsewhere
 - R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged); keydown handling in `handleGridKeyDown(event, context)` (`hooks/core/gridKeyDown.ts`, a plain function over a typed `GridKeyDownContext`, unit-testable without rendering; `useGridKeyboardNavigation` calls it from a `useCallback` with the same dependencies). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
 - Demo code samples go through `demo/components/CodeBlock.tsx` (prism-react-renderer, demo-only devDependency); live output panels (JSON state, logs) stay plain `
`
 
diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md
index 8f49fb98..0fef554f 100644
--- a/docs/superpowers/plans/2026-10-13-audit-tracker.md
+++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md
@@ -44,7 +44,7 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 | R1 | Remove dead code: `lib/theme/defaultStyles.ts`, 12 unused util exports, 9 never-emitted CSS classes, 27 unread CSS vars; decide on `QuickFilter` CSS | S | ✅ | 11 dead functions + `defaultStyles.ts` removed; 9 never-set class rules removed; CSS vars kept (public tokens); theme baseline unchanged |
 | R2 | Shared filter-value helper for FilterPanel + HeaderFilterCell (6 valueOptions readers) | S–M | ✅ | `lib/utils/filtering/filterInput.ts` (4 helpers) used by FilterPanel + HeaderFilterCell |
 | R3 | Stop writing `_isSkeleton` onto rows | M | ✅ | `Row` `skeleton` prop; `_isSkeleton` removed from `GridRowModel` and editing/spanning |
-| R4 | Reduce ~41 `as unknown as` casts (internal column type) | L | ⬜ | |
+| R4 | Reduce ~41 `as unknown as` casts (internal column type) | L | ✅ | 43 → 15 `as unknown as`: pipeline / data source / grouping called with ``; store typed with `R`; column widenings through one `untypedColumns` helper. One cast had hidden ~25 type errors. Remaining 15 are synthetic rows, third-party typings, window/CSS |
 | R5 | Split files >600 lines (`GridToolbar.tsx` 855, …) | M each | ✅ | GridToolbar 861→492 (panels + `useToolbarPanel`), exporters one file per format, xlsx helpers → `excelCells`, range drag → `useGridCellRangeDrag`, keydown → `gridKeyDown.ts`. `DataGrid.tsx` (618) left: it is the documented hook order |
 | R6 | Narrow wide hook interfaces (keyboard nav: 31 params) | L | ✅ | Keyboard: the 225-line keydown body is `handleGridKeyDown(event, GridKeyDownContext)`, a typed, unit-tested function; regrouping the 31 params into objects was rejected (inline objects would break memoised callbacks) |
 | R7 | Route ~70 hard-coded English strings through `localeText` | M | ✅ | 196 keys (more than the ~70 estimated: pivot/toolbar/AI status included), `GridLocaleContext`, `useShallowStableValue`. Not covered: AI chip texts, grouping column default header, file/row contents |
@@ -75,3 +75,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 - 2026-10-13 — Docs sweep (roadmap, performance, README, wiki home, rtl, pagination, architecture). Found and fixed: npm package shipped `lib/theme/__baseline__` (1.6 MB). Feature hold recorded (user).
 - 2026-10-13 — Backlog 1/6: demo syntax highlighting done. Insight follow-ups: `check:package` (no test artifacts in the tarball + DataGrid-only 92 KB gz budget) in CI and publish.
 - 2026-10-13 — R5 and R6 done (all engines green, compiler baseline). Next: R4 casts.
+- 2026-10-13 — R4 done (43 → 15 casts). All R items done.
diff --git a/lib/components/Cell/Cell.tsx b/lib/components/Cell/Cell.tsx
index 3d3bdb32..64df9584 100644
--- a/lib/components/Cell/Cell.tsx
+++ b/lib/components/Cell/Cell.tsx
@@ -8,6 +8,7 @@ import { CellErrorBoundary } from './CellErrorBoundary';
 import { formatValueByType } from '../../utils/values';
 import { attempt } from '../../utils/attempt';
 import { RANGE_BOTTOM, RANGE_CELL, RANGE_FILL_HANDLE, RANGE_LEFT, RANGE_RIGHT, RANGE_TOP } from '../../utils/cellSelection';
+import { untypedColumn } from '../../utils/untypedColumns';
 
 export interface CellProps {
     onCellClick?: (params: GridCellParams) => void;
@@ -306,7 +307,7 @@ function CellImpl(props: CellProps) {
                             formattedValue={formattedValue}
                             row={row}
                             field={colDef.field}
-                            colDef={colDef as unknown as GridColDef}
+                            colDef={untypedColumn(colDef)}
                             rowIndex={rowIndex}
                             colIndex={colIndex}
                             rowMeta={rowMeta}
diff --git a/lib/components/DataGrid/DataGrid.tsx b/lib/components/DataGrid/DataGrid.tsx
index 79ccf8d5..ad4af3f9 100644
--- a/lib/components/DataGrid/DataGrid.tsx
+++ b/lib/components/DataGrid/DataGrid.tsx
@@ -3,7 +3,7 @@ import { useLayout } from '../../hooks/core/useLayout';
 import { useGridKeyboardNavigation } from '../../hooks/core/useGridKeyboardNavigation';
 import { useGridControlledState } from '../../hooks/core/useGridControlledState';
 import { useGridThemeDimensions } from '../../hooks/core/useGridThemeDimensions';
-import { useGridRowPipeline, type GridRowPipelineResult } from '../../hooks/core/useGridRowPipeline';
+import { useGridRowPipeline } from '../../hooks/core/useGridRowPipeline';
 import { countReachableRows } from '../../utils/maxElementHeight';
 import { useGridColumns } from '../../hooks/core/useGridColumns';
 import { useGridDetailPanel } from '../../hooks/core/useGridDetailPanel';
@@ -44,7 +44,8 @@ import { useGridKeyboardMode, useGridPointerFocusHandlers } from '../../hooks/co
 import { getPaginationRowCount } from '../../utils/pagination';
 import { useGridToolbarStack } from '../../hooks/core/useGridToolbarStack';
 import { DEFAULT_HEADER_FILTER_HEIGHT } from '../../utils/headerFilters';
-import type { DataGridProps, DataGridUntypedColumnsProps, GridValidRowModel, GridRowModel, GridRowId, GridColDef } from '../../types';
+import type { DataGridProps, DataGridUntypedColumnsProps, GridValidRowModel, GridRowModel, GridColDef } from '../../types';
+import { untypedColumns } from '../../utils/untypedColumns';
 
 /**
  * The grid. `R` is your row type: any object type (an interface works), inferred from `rows`. `columns`
@@ -133,7 +134,7 @@ export function DataGrid(props: DataGridP
 
     const pivot = useGridPivot({
         rows: rows as GridRowModel[],
-        columns: columns as unknown as GridColDef[],
+        columns: untypedColumns(columns),
         pivotMode,
         pivotModel: currentPivotModel,
         filterModel,
@@ -166,7 +167,7 @@ export function DataGrid(props: DataGridP
     });
 
     const activeRows = pivot.rows as unknown as R[];
-    const baseColumns = pivot.columns as unknown as GridColDef[];
+    const baseColumns = pivot.columns as GridColDef[];
 
     const { activeColumns, effectivePinnedColumns } = useGridGroupingColumn({
         baseColumns, pinnedColumns, hasGroupingColumn, groupingColDef, isTreeData, getTreeDataPath,
@@ -222,13 +223,13 @@ export function DataGrid(props: DataGridP
 
     // Steps 12a–13: the edit commit channel, the batch edit and single-cell editing.
     const { editCommitChannel, batchEdit, editingHandlers } = useGridEditPipeline({
-        rowsLookup: state.rows.idRowsLookup as unknown as ReadonlyMap,
+        rowsLookup: gridData.rowsLookup,
         rows: effectiveRows,
         getRowId: getRowIdOf,
         columns: activeColumns,
         processRowUpdate,
         onProcessRowUpdateError,
-        replaceRows: replaceRows as unknown as (rows: ReadonlyMap) => void,
+        replaceRows,
     });
 
     // The selection without ids of removed rows. Only client-owned rows are pruned: a server page
@@ -241,12 +242,12 @@ export function DataGrid(props: DataGridP
         onSelectionModelChange: handleRowSelectionModelChange,
     });
 
-    const dataSourceHandlers = useGridDataSource({
+    const dataSourceHandlers = useGridDataSource({
         dataSource, sortModel, filterModel,
         paginationModel: effectivePaginationModel,
         paginationMode, sortingMode, filterMode, aggregationModel,
         rowGroupingActive: isRowGrouping,
-        getRowId: effectiveGetRowId as unknown as (row: GridRowModel) => GridRowId,
+        getRowId: effectiveGetRowId,
         setRows, setRowCount, setDataSourceLoading, setDataSourceError,
         onAggregationResults: setServerAggregationResults,
         onPaginationModelChange: handlePaginationModelChange,
@@ -290,8 +291,8 @@ export function DataGrid(props: DataGridP
         density: effectiveDensity,
     });
 
-    const rowPipeline = useGridRowPipeline({
-        effectiveRows: effectiveRows as GridRowModel[],
+    const rowPipeline = useGridRowPipeline({
+        effectiveRows,
         activeHierarchyHandlers,
         filterMode,
         filterModel: pivot.pipelineFilterModel,
@@ -303,7 +304,7 @@ export function DataGrid(props: DataGridP
     });
     const {
         filteredRows, dataRows, pinnedTopRows, pinnedBottomRows, sortedUnpinnedRows, paginatedUnpinnedRows, allRenderableRows,
-    } = rowPipeline as unknown as GridRowPipelineResult;
+    } = rowPipeline;
     // The unpinned rows on screen: the current page, or every row without pagination.
     const centerRows = pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
 
@@ -345,7 +346,7 @@ export function DataGrid(props: DataGridP
                 hierarchyField,
                 dataRows,
             }),
-            visibleColumns: renderOrderedColumns as unknown as GridColDef[],
+            visibleColumns: renderOrderedColumns,
         },
     });
 
@@ -359,7 +360,7 @@ export function DataGrid(props: DataGridP
 
     const { aggregationResult, isLoading: isAggregationLoading } = useAggregation({
         rows: dataRows,
-        columns: activeColumns as unknown as GridColDef[],
+        columns: untypedColumns(activeColumns),
         // The pivot's Grand Total row is the aggregate in pivot mode: nothing to total here.
         aggregationModel: isPivotActive ? NO_AGGREGATION_MODEL : aggregationModel,
         // When the server drives the rows (paginates, sorts or filters them), the grid only holds
@@ -437,8 +438,8 @@ export function DataGrid(props: DataGridP
     // Steps 30–31a: copy, scrollToIndexes and column auto-size on the API.
     const handleColumnAutosize = useGridViewApis({
         apiRef, containerRef, viewportRef,
-        copyColumns: renderedDataColumns as unknown as GridColDef[],
-        getRowId: getRowIdOf as unknown as (row: GridRowModel) => GridRowId,
+        copyColumns: renderedDataColumns,
+        getRowId: getRowIdOf,
         disableClipboardCopy,
         getCellRangeCopyText: cellSelection.enabled ? cellSelection.getCopyText : undefined,
         layout,
@@ -497,7 +498,7 @@ export function DataGrid(props: DataGridP
         getPendingModel: controlledState.getPendingCellSelectionModel,
         focusedCell, setFocusedCell, allRenderableRows,
         getRowId: getRowIdOf,
-        navigationColumns: navigationColumns as unknown as GridColDef[],
+        navigationColumns,
         columnIndexMap, isCellEditable, rowMetaMap,
         getSpanOrigin: spanning.getSpanOrigin,
         isEditing: editingHandlers.editingCell !== null,
@@ -559,7 +560,7 @@ export function DataGrid(props: DataGridP
         apiRef, containerRef, localeText,
         aiAssistant: {
             options: aiAssistant,
-            columns: activeColumns as unknown as GridColDef[],
+            columns: untypedColumns(activeColumns),
             onApply: onAiAssistantApply,
             onError: onAiAssistantError,
         },
@@ -598,7 +599,7 @@ export function DataGrid(props: DataGridP
         pinnedTopRows, pinnedBottomRows, pinnedRows, getRowIdOf,
         showEmptyState: !effectiveLoading && !state.dataSource.error && filteredRows.length === 0,
         effectiveNoRowsLabel, spanRowWindow, visibleRows,
-        baseColumns: columns as unknown as GridColDef[], viewportWidth: state.dimensions.viewportWidth,
+        baseColumns: columns as GridColDef[], viewportWidth: state.dimensions.viewportWidth,
         rowReorderHandlers, paginationMode, dataSourceLoading: state.dataSource.loading,
         sortedUnpinnedRowCount: sortedUnpinnedRows.length, pageSize: effectivePaginationModel.pageSize,
         unpinnedRowsLength: layout.unpinnedRowsLength,
diff --git a/lib/components/DataGrid/GridViewport.tsx b/lib/components/DataGrid/GridViewport.tsx
index 32d50157..0b75b50c 100644
--- a/lib/components/DataGrid/GridViewport.tsx
+++ b/lib/components/DataGrid/GridViewport.tsx
@@ -15,6 +15,7 @@ import type { UseGridViewportResult } from '../../hooks/core/useGridViewport';
 import type { GridVirtualizationResult } from '../../hooks/core/useGridVirtualization';
 import type { UseColumnReorderReturn } from '../../hooks/useColumnReorder';
 import type { GridSpanningResult } from '../../hooks/features/useGridSpanning';
+import { untypedColumns } from '../../utils/untypedColumns';
 
 /** The props the row renderers (top-pinned, centre and bottom-pinned rows) all take. */
 export type GridRowRenderProps = Pick,
@@ -294,7 +295,7 @@ export function GridViewport(props: GridViewportProps
                          hidden, pinned and scrolled columns line up. Off in pivot mode (hasAggregation). */}
                     {showAggregationFooter && (
                         (props: HeaderProps
                     };
 
                     const headerContent = colDef.renderHeader
-                        ? colDef.renderHeader({ field: colDef.field, colDef: colDef as unknown as GridColDef, colIndex })
+                        ? colDef.renderHeader({ field: colDef.field, colDef: untypedColumn(colDef), colIndex })
                         : colDef.headerName || colDef.field;
 
                     const handleHeaderClick = (e: React.MouseEvent) => {
@@ -560,7 +561,7 @@ export function Header(props: HeaderProps
 
                 {menuOpenParams && (
                     }
+                        colDef={untypedColumn(menuOpenParams.colDef)}
                         sortModel={sortModel}
                         onSort={onSort || onSortAdd ? handleMenuSort : undefined}
                         onHide={onHideColumn}
@@ -576,7 +577,7 @@ export function Header(props: HeaderProps
             {/* ── Header filter row ── */}
             {headerFilters && (
                  {
     cellRange?: GridRowCellRange | null;
 }
 
+// The system columns' definitions passed to error params of their cells (stable, module level).
+const EXPAND_COLUMN: GridColDef = { field: '__expand_col__', width: 48 };
+const CHECKBOX_COLUMN: GridColDef = { field: '__checkbox_col__', width: 48 };
+
 function RowImpl(props: RowProps) {
     const locale = useGridLocale();
     const {
@@ -341,7 +345,7 @@ function RowImpl(props: RowProps) {
                                 row,
                                 field: '__expand_col__',
                                 value: isDetailPanelExpanded,
-                                colDef: { field: '__expand_col__', width: 48 } as unknown as GridColDef,
+                                colDef: EXPAND_COLUMN as GridColDef,
                                 rowIndex,
                                 colIndex: -1
                             });
@@ -379,7 +383,7 @@ function RowImpl(props: RowProps) {
                                 row,
                                 field: '__checkbox_col__',
                                 value: isSelected,
-                                colDef: { field: '__checkbox_col__', width: 48 } as unknown as GridColDef,
+                                colDef: CHECKBOX_COLUMN as GridColDef,
                                 rowIndex,
                                 colIndex: -1
                             });
diff --git a/lib/hooks/core/useDataGrid.ts b/lib/hooks/core/useDataGrid.ts
index 616793e2..e6b27e75 100644
--- a/lib/hooks/core/useDataGrid.ts
+++ b/lib/hooks/core/useDataGrid.ts
@@ -9,6 +9,7 @@ import type {
   GridApi
 } from '../../types';
 import { createGridApiPlaceholder } from './gridApiPlaceholder';
+import { untypedColumn, untypedColumns } from '../../utils/untypedColumns';
 
 // Sort, filter, pagination and selection deliberately have no slice here: they live in
 // useGridControlledState, which is what the grid renders from. (Copies of them in this
@@ -76,14 +77,14 @@ function createInitialState(rows: R[], getRowId: GetRowI
   const orderedFields: string[] = [];
 
   columns.forEach(col => {
-    columnLookup.set(col.field, col as unknown as GridColDef);
+    columnLookup.set(col.field, untypedColumn(col));
     orderedFields.push(col.field);
   });
 
   return {
     rows: buildRowsState(rows, getRowId),
     columns: {
-      all: columns as unknown as GridColDef[],
+      all: untypedColumns(columns),
       lookup: columnLookup,
       orderedFields,
       columnVisibilityModel
@@ -287,8 +288,13 @@ export function useDataGrid(params: UseDa
 
   // `_preserveRowCount` is still accepted from callers written against the old signature;
   // replacing rows never changes the server row count any more.
-  const setRows = useCallback((rowsOrUpdater: GridRowModel[] | ((prev: GridRowModel[]) => GridRowModel[]), _preserveRowCount?: boolean) => {
-    dispatch({ type: 'SET_ROWS', payload: rowsOrUpdater, getRowId: getRowIdRef.current });
+  // Typed with the grid's own row type `R`. The store holds those very objects (never copies), so the
+  // stored rows read back here are `R` again: this is the one place the store's untyped rows narrow.
+  const setRows = useCallback((rowsOrUpdater: R[] | ((prev: R[]) => R[]), _preserveRowCount?: boolean) => {
+    const payload = typeof rowsOrUpdater === 'function'
+      ? (prev: GridRowModel[]) => rowsOrUpdater(prev as R[])
+      : rowsOrUpdater;
+    dispatch({ type: 'SET_ROWS', payload, getRowId: getRowIdRef.current });
   }, []);
 
   /** Replaces the row stored under `id` (a committed edit); does nothing if that row is gone. */
@@ -297,7 +303,7 @@ export function useDataGrid(params: UseDa
   }, []);
 
   /** Replaces several stored rows in one update (a batch edit); ids no longer stored are ignored. */
-  const replaceRows = useCallback((rowsById: ReadonlyMap) => {
+  const replaceRows = useCallback((rowsById: ReadonlyMap) => {
     if (rowsById.size > 0) dispatch({ type: 'REPLACE_ROWS', rows: rowsById });
   }, []);
 
@@ -334,6 +340,8 @@ export function useDataGrid(params: UseDa
 
   return {
     state,
+    /** The stored rows by id, typed with the grid's row type (see `setRows`). */
+    rowsLookup: (state.rows.idRowsLookup as ReadonlyMap) as ReadonlyMap,
     dispatch,
     apiRef,
     setRows,
diff --git a/lib/hooks/core/useGridColumnLookup.ts b/lib/hooks/core/useGridColumnLookup.ts
index ba6a9ca9..066cc7dd 100644
--- a/lib/hooks/core/useGridColumnLookup.ts
+++ b/lib/hooks/core/useGridColumnLookup.ts
@@ -1,6 +1,7 @@
 import { useMemo } from 'react';
 import type { GridColDef, GridRowModel } from '../../types';
 import { buildColumnLookup, type GridColumnLookup } from '../../utils/columnLookup';
+import { untypedColumns } from '../../utils/untypedColumns';
 
 const SEPARATOR = '\u0000';
 
@@ -25,6 +26,6 @@ export function useGridColumnLookup(
         if (hiddenFieldsKey) {
             for (const field of hiddenFieldsKey.split(SEPARATOR)) hidden[field] = false;
         }
-        return buildColumnLookup(columns as unknown as GridColDef[], hidden);
+        return buildColumnLookup(untypedColumns(columns), hidden);
     }, [columns, hiddenFieldsKey]);
 }
diff --git a/lib/hooks/core/useGridColumns.tsx b/lib/hooks/core/useGridColumns.tsx
index 189ed168..184a74c3 100644
--- a/lib/hooks/core/useGridColumns.tsx
+++ b/lib/hooks/core/useGridColumns.tsx
@@ -15,6 +15,7 @@ import type {
     GridAggregationModel,
 } from '../../types';
 import type { GridInitialState } from '../../state/types';
+import { untypedColumn, untypedColumns } from '../../utils/untypedColumns';
 
 interface HierarchyHandlers {
     toggleExpansion: (id: GridRowId) => void;
@@ -334,7 +335,7 @@ export function useGridColumns(
     }, []);
 
     useEffect(() => {
-        setColumns(activeColumns as unknown as GridColDef[]);
+        setColumns(untypedColumns(activeColumns));
     }, [activeColumns, setColumns]);
 
     // ── Ordered / visible columns ─────────────────────────────────────────────
@@ -370,7 +371,7 @@ export function useGridColumns(
         const [movedField] = newOrder.splice(oldIndex, 1);
         newOrder.splice(targetIndex, 0, movedField);
         commitColumnOrder(newOrder, {
-            column: orderedColumns[oldIndex] as unknown as GridColDef,
+            column: untypedColumn(orderedColumns[oldIndex]),
             oldIndex,
             targetIndex,
         });
diff --git a/lib/hooks/core/useGridHierarchy.ts b/lib/hooks/core/useGridHierarchy.ts
index 0fc94ca0..9189a167 100644
--- a/lib/hooks/core/useGridHierarchy.ts
+++ b/lib/hooks/core/useGridHierarchy.ts
@@ -54,7 +54,7 @@ export function useGridHierarchy(params: UseGridHierarch
         sortingMode,
     });
 
-    const rowGroupingHandlers = useRowGrouping({
+    const rowGroupingHandlers = useRowGrouping({
         rows,
         getRowId,
         columns,
diff --git a/lib/hooks/core/useGridToolbarProps.ts b/lib/hooks/core/useGridToolbarProps.ts
index 777315af..5e6cddb5 100644
--- a/lib/hooks/core/useGridToolbarProps.ts
+++ b/lib/hooks/core/useGridToolbarProps.ts
@@ -13,6 +13,7 @@ import type {
     GridRowModel,
     GridToolbarSlotProps,
 } from '../../types';
+import { untypedColumns } from '../../utils/untypedColumns';
 
 type ToolbarSlot = NonNullable['toolbar'];
 type ToolbarSlotProps = NonNullable['toolbar'];
@@ -79,8 +80,8 @@ export function useGridToolbarProps(
             };
         return {
             apiRef,
-            columns: orderedColumns as unknown as GridColDef[],
-            baseColumns: baseColumns as unknown as GridColDef[],
+            columns: untypedColumns(orderedColumns),
+            baseColumns: baseColumns && untypedColumns(baseColumns),
             aggregationModel,
             onAggregationModelChange,
             ...(pivotMode || pivotModelProp || onPivotModelChangeProp ? {
diff --git a/lib/hooks/useColumnReorder.ts b/lib/hooks/useColumnReorder.ts
index eb547ec2..db6faccc 100644
--- a/lib/hooks/useColumnReorder.ts
+++ b/lib/hooks/useColumnReorder.ts
@@ -1,6 +1,7 @@
 import { useState, useCallback, useRef, useEffect } from 'react';
 import type { GridColDef, GridRowModel, GridColumnOrderChangeParams } from '../types';
 import { COLUMN_DRAG_MIME, hasDragToken, onNativeDragEnd, setDragToken } from '../utils/dragData';
+import { untypedColumn } from '../utils/untypedColumns';
 
 export interface UseColumnReorderProps {
   /** Every current column, in the grid's column order. Reported indices are positions in it. */
@@ -89,7 +90,7 @@ export function useColumnReorder(
       if (oldIndex === -1 || targetIndex === -1 || oldIndex === targetIndex) return;
 
       onColumnOrderChange?.({
-        column: columns[oldIndex] as unknown as GridColDef,
+        column: untypedColumn(columns[oldIndex]),
         oldIndex,
         targetIndex,
       });
diff --git a/lib/hooks/useTreeData.ts b/lib/hooks/useTreeData.ts
index 954bd6cc..47c65a97 100644
--- a/lib/hooks/useTreeData.ts
+++ b/lib/hooks/useTreeData.ts
@@ -319,7 +319,9 @@ export function useTreeData(props: UseTreeDataProps)
      * indent and the labels of auto-created parents): sorting by it orders auto-created parents by
      * their label, since they have no cell values of their own.
      */
-    const getVisibleRows = useCallback((options?: GridHierarchyVisibleRowsOptions): GridRowModel[] | null => {
+    // Generated parent rows (missing path ancestors) are rendered like the consumer's rows and typed as
+    // `R`, as row grouping's group rows are: the grid's documented convention for rendered rows.
+    const getVisibleRows = useCallback((options?: GridHierarchyVisibleRowsOptions): R[] | null => {
         if (!isActive) return null;
         const labelField = options?.labelField;
         const expandAll = options?.expandAll ?? false;
@@ -364,7 +366,7 @@ export function useTreeData(props: UseTreeDataProps)
         };
 
         traverse(rootIds);
-        return result;
+        return result as R[];
     }, [isActive, visibility, isClientSort, sortModel, syntheticIds, treeNodes, rowLookup, columnLookup, rootIds, expandedGroupIds]);
 
     const isGroupExpanded = useCallback((id: GridRowId) => expandedGroupIds.has(id), [expandedGroupIds]);
diff --git a/lib/utils/untypedColumns.ts b/lib/utils/untypedColumns.ts
new file mode 100644
index 00000000..1a8a940e
--- /dev/null
+++ b/lib/utils/untypedColumns.ts
@@ -0,0 +1,16 @@
+import type { GridColDef, GridRowModel } from '../types';
+
+/**
+ * A column typed for the grid's rows (`GridColDef`) as the untyped `GridColDef` that public
+ * callback parameters, the toolbar and the row store use. TypeScript rejects the plain widening
+ * because the column's callbacks take `R` (`strictFunctionTypes`); it is sound here because the grid
+ * only ever calls them with its own rows. The one place this widening is written (R4, 3.10).
+ */
+export function untypedColumn(column: GridColDef): GridColDef {
+    return column as unknown as GridColDef;
+}
+
+/** `untypedColumn` for a list. */
+export function untypedColumns(columns: readonly GridColDef[]): GridColDef[] {
+    return columns as unknown as GridColDef[];
+}

From 0d5cdd9bff7fc944d1b659d7d435174a380299e9 Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 18:13:34 +0530
Subject: [PATCH 13/18] ci: turn the review insights into guards

- check:versions: llms.txt, its demo copy, CLAUDE.md and CHANGELOG must name
  the package.json version
- check:compiler fails on anything outside its allowlisted baseline; runs in CI
- ESLint: no-explicit-any on (demo's any types fixed); no new as-unknown-as in
  lib/ outside the documented boundary files
---
 .github/workflows/ci.yml                      |  6 ++++
 .github/workflows/npm-publish.yml             |  3 ++
 CHANGELOG.md                                  |  2 ++
 CLAUDE.md                                     |  1 +
 .../AdvancedFilteringDemo.tsx                 |  5 ++--
 .../LoadingStatesDemo/LoadingStatesDemo.tsx   |  2 +-
 demo/examples/ThemingDemo/ThemingDemo.tsx     | 14 +++++----
 demo/mockData.ts                              |  4 +--
 .../plans/2026-10-13-audit-tracker.md         |  1 +
 eslint.config.js                              | 30 ++++++++++++++++++-
 package.json                                  |  3 +-
 scripts/check-react-compiler.mjs              | 21 +++++++++++++
 scripts/check-versions.mjs                    | 25 ++++++++++++++++
 13 files changed, 105 insertions(+), 12 deletions(-)
 create mode 100644 scripts/check-versions.mjs

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7412406f..44f2d994 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,6 +38,12 @@ jobs:
       - name: Lint
         run: npm run lint
 
+      - name: Versions agree (llms.txt, CLAUDE.md, CHANGELOG vs package.json)
+        run: npm run check:versions
+
+      - name: React Compiler baseline (no new CompileError or skip)
+        run: npm run check:compiler
+
       - name: Type-check (lib, tests, demo, e2e)
         run: npm run typecheck
 
diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml
index ce9713e9..d62e5572 100644
--- a/.github/workflows/npm-publish.yml
+++ b/.github/workflows/npm-publish.yml
@@ -50,6 +50,9 @@ jobs:
       - name: Package contents and size budget
         run: npm run check:package
 
+      - name: Versions agree (llms.txt, CLAUDE.md, CHANGELOG vs package.json)
+        run: npm run check:versions
+
       - name: Package smoke suite (packed tarball in React 18 and 19 apps)
         run: npm run test:smoke -- --skip-lib
 
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 452707f3..544afe43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,8 @@
 
 ### Internal
 
+- **Guards that fail CI instead of relying on review:** `check:versions` (llms.txt, CLAUDE.md and CHANGELOG must name the `package.json` version), `check:compiler` (fails on any React Compiler result outside a short allowlist, so a refactor that stops it from optimizing a hook is caught), ESLint `no-explicit-any` turned on (it was off; the library had none, the demo's 9 are now typed) and a rule against new `as unknown as` casts in the library outside the documented boundary files.
+
 - **Refactors (no behaviour change):** `GridToolbar` split into its panels with a shared `useToolbarPanel` (861 → 492 lines); the exporters one file per format; the xlsx cell helpers in `excelCells`; the cell range drag in `useGridCellRangeDrag`; keydown handling in a plain, unit-tested `handleGridKeyDown` function. Type casts `as unknown as` 43 → 15: the row pipeline, data source and grouping are called with the row type, the row store is typed with it, and column widenings go through one documented helper. DOM, styles and behaviour unchanged (theme baseline and the full browser suite in three engines).
 
 - **`npm run check:package`**, run in CI and before publishing: fails when `npm pack` would ship a test artifact (the gap that let the theme baselines through) or when `DataGrid` alone, minified and gzipped, grows past its budget (92 KB; 85.5 KB now).
diff --git a/CLAUDE.md b/CLAUDE.md
index 02d06549..e1a00b63 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -162,6 +162,7 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs`
 - **`npm run check:package`** (after `build:lib`; in `ci.yml` and `npm-publish.yml`): fails if `npm pack` would ship a test artifact (`*.test.*`, `__baseline__`, `__screenshots__`, `__snapshots__`, `__fixtures__`) and if `DataGrid` alone, minified with esbuild and gzipped, exceeds `DATAGRID_GZIP_BUDGET` (92 KB; 85.5 KB at 3.9.0). `files` in package.json works by exclusion: add a negation for any new kind of test folder under `lib/`. Raise the budget only in the commit that needs it
 ## v3.10.0 — significant changes (unreleased)
 - R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
+- **CI guards** (each fails the build): `check:versions` (`llms.txt` + its demo copy, CLAUDE.md "Current version" and a CHANGELOG entry must match `package.json`; bump them together), `check:compiler` (React Compiler: only the allowlisted `EXPECTED` results in `scripts/check-react-compiler.mjs`), `check:package` (no test artifacts, DataGrid-only size budget), ESLint `no-explicit-any` (now `error`, also in the demo) and `no-restricted-syntax` against `as unknown as` in `lib/` outside the boundary files listed in `eslint.config.js`
 - **Types (R4)**: a `GridColDef` passed to an untyped public surface (callback params, toolbar, store) goes through `untypedColumn` / `untypedColumns` (`lib/utils/untypedColumns.ts`), the one place that widening is written; narrowing untyped → `GridColDef` / `R[]` is a plain `as`. `useDataGrid` types `setRows` / `replaceRows` / `rowsLookup` with `R`; call generic hooks with `` explicitly when an untyped argument would otherwise infer `GridRowModel`. Remaining `as unknown as` (15) are synthetic rows, third-party module typings, `window` / CSS custom properties: do not add new ones elsewhere
 - R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged); keydown handling in `handleGridKeyDown(event, context)` (`hooks/core/gridKeyDown.ts`, a plain function over a typed `GridKeyDownContext`, unit-testable without rendering; `useGridKeyboardNavigation` calls it from a `useCallback` with the same dependencies). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
 - Demo code samples go through `demo/components/CodeBlock.tsx` (prism-react-renderer, demo-only devDependency); live output panels (JSON state, logs) stay plain `
`
diff --git a/demo/examples/AdvancedFilteringDemo/AdvancedFilteringDemo.tsx b/demo/examples/AdvancedFilteringDemo/AdvancedFilteringDemo.tsx
index db93f10d..6edcf256 100644
--- a/demo/examples/AdvancedFilteringDemo/AdvancedFilteringDemo.tsx
+++ b/demo/examples/AdvancedFilteringDemo/AdvancedFilteringDemo.tsx
@@ -1,6 +1,7 @@
 
 import { useState } from 'react';
 import { DataGrid, GridToolbar, GridColDef, GridRowModel, GridFilterModel, GridTooltip, exportToCsv, exportToExcel, exportToJson, printGrid } from '@opencorestack/opengridx';
+import type { GridApi, GridToolbarSlotProps } from '@opencorestack/opengridx';
 import { DocsLayout } from '../../components/DocsLayout';
 import './AdvancedFilteringDemo.css';
 
@@ -17,7 +18,7 @@ interface Employee extends GridRowModel {
 }
 
 interface ExportToolbarProps {
-    apiRef?: any;
+    apiRef?: React.RefObject;
     fallbackRows: Employee[];
     fallbackColumns: GridColDef[];
 }
@@ -161,7 +162,7 @@ const columns: GridColDef[] = [
     { field: 'active', headerName: 'Active', width: 90, type: 'boolean' },
 ];
 
-function ToolbarWithExport(props: any) {
+function ToolbarWithExport(props: GridToolbarSlotProps) {
     return (
         
                     
                 
diff --git a/demo/examples/ThemingDemo/ThemingDemo.tsx b/demo/examples/ThemingDemo/ThemingDemo.tsx
index 5265208f..7b30acfe 100644
--- a/demo/examples/ThemingDemo/ThemingDemo.tsx
+++ b/demo/examples/ThemingDemo/ThemingDemo.tsx
@@ -25,6 +25,7 @@ import {
 import type { GridTheme } from '@opencorestack/opengridx';
 import { generateEmployees } from '../../data/mockData';
 import { allColumns } from '../../data/columns';
+import type { GridApi, GridRowModel, GridToolbarSlotProps } from '@opencorestack/opengridx';
 import { DocsLayout } from '../../components/DocsLayout';
 import './ThemingDemo.css';
 
@@ -84,9 +85,9 @@ const Icons = {
 };
 
 interface ExportToolbarProps {
-    apiRef?: any;
-    fallbackRows: any[];
-    fallbackColumns: GridColDef[];
+    apiRef?: React.RefObject;
+    fallbackRows: GridRowModel[];
+    fallbackColumns: GridColDef[];
 }
 
 function ExportToolbar({ apiRef, fallbackRows, fallbackColumns }: ExportToolbarProps) {
@@ -105,7 +106,7 @@ function ExportToolbar({ apiRef, fallbackRows, fallbackColumns }: ExportToolbarP
         const selectedIds: (string | number)[] = apiRef?.current?.getSelectedRows?.() || [];
         if (selectedIds.length > 0) {
             const allRows = getAllRows();
-            return allRows.filter((r: any) => selectedIds.includes(r.id));
+            return allRows.filter((r: GridRowModel) => selectedIds.includes(r.id));
         }
         return getAllRows();
     };
@@ -192,7 +193,10 @@ function ExportToolbar({ apiRef, fallbackRows, fallbackColumns }: ExportToolbarP
 // If this were defined inside the component body, every render would create a
 // new function reference, causing React to unmount/remount the entire toolbar
 // and losing all state (search expansion, filter panel open, etc.).
-function ToolbarWithExport(props: any) {
+/** The toolbar slot plus the demo's own fallback data, passed through `slotProps.toolbar`. */
+type ExportToolbarSlotProps = GridToolbarSlotProps & { _fallbackRows?: GridRowModel[]; _fallbackColumns?: GridColDef[] };
+
+function ToolbarWithExport(props: ExportToolbarSlotProps) {
     return (
          TSAsExpression.expression[typeAnnotation.type='TSUnknownKeyword']",
+        message: 'No `as unknown as` in lib/: widen columns with untypedColumn(s), narrow with a plain `as`, or type the call (`useHook(...)`). See the R4 note in CLAUDE.md.',
+      }],
+    },
+  },
 ])
diff --git a/package.json b/package.json
index dee72f15..13d71e25 100644
--- a/package.json
+++ b/package.json
@@ -67,7 +67,8 @@
     "prepublishOnly": "npm run build:lib",
     "wiki:build": "node scripts/build-wiki.mjs",
     "check:compiler": "node scripts/check-react-compiler.mjs",
-    "check:package": "node scripts/check-package.mjs"
+    "check:package": "node scripts/check-package.mjs",
+    "check:versions": "node scripts/check-versions.mjs"
   },
   "keywords": [
     "react",
diff --git a/scripts/check-react-compiler.mjs b/scripts/check-react-compiler.mjs
index db8197ac..8a0c6194 100644
--- a/scripts/check-react-compiler.mjs
+++ b/scripts/check-react-compiler.mjs
@@ -69,3 +69,24 @@ for (const { file, event } of problems) {
 }
 
 console.log('\nReact Compiler over lib/:', Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(', ') || 'nothing to compile');
+
+// The known baseline. Anything else fails the check (and CI): a new CompileError means React Compiler
+// stopped optimizing a hook or component, e.g. PreserveManualMemo after a refactor turned a local ref
+// into a parameter without listing it in a dependency array.
+const EXPECTED = [
+    // try/finally is not supported by the compiler yet; the hook keeps manual memoization.
+    { kind: 'CompileError', file: 'lib/hooks/features/useGridDataSource.ts' },
+    // They call consumer functions that may use hooks, so they opt out with 'use no memo'.
+    { kind: 'CompileSkip', file: 'lib/components/Cell/CellErrorBoundary.tsx' },
+    { kind: 'CompileSkip', file: 'lib/components/DataGrid/GridToolbarSlot.tsx' },
+];
+const isExpected = ({ file, event }) => EXPECTED.some(e => e.kind === event.kind && file.endsWith(e.file));
+const unexpected = problems.filter(p => !isExpected(p));
+const missing = EXPECTED.filter(e => !problems.some(p => p.event.kind === e.kind && p.file.endsWith(e.file)));
+if (unexpected.length > 0) {
+    console.error(`\ncheck:compiler: ${unexpected.length} unexpected result(s) above. Fix them (see docs/contributing/react-compiler.md), or, if intended, add them to EXPECTED in scripts/check-react-compiler.mjs.`);
+    process.exit(1);
+}
+if (missing.length > 0) {
+    console.log(`\ncheck:compiler: ${missing.map(m => `${m.kind} ${m.file}`).join(', ')} no longer reported: remove from EXPECTED.`);
+}
diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs
new file mode 100644
index 00000000..4f4ee2e9
--- /dev/null
+++ b/scripts/check-versions.mjs
@@ -0,0 +1,25 @@
+// Every place that names the current version agrees with package.json, so a release cannot leave a
+// stale "3.7.0" behind (the demo reads package.json itself; these files are written by hand).
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+const ROOT = resolve(import.meta.dirname, '..');
+const read = (path) => readFileSync(resolve(ROOT, path), 'utf8');
+const { version } = JSON.parse(read('package.json'));
+const problems = [];
+
+const llms = read('llms.txt');
+const llmsVersion = llms.match(/^# Version: (\S+)$/m)?.[1];
+if (llmsVersion !== version) problems.push(`llms.txt says "# Version: ${llmsVersion}"`);
+if (read('demo/public/llms.txt') !== llms) problems.push('demo/public/llms.txt differs from llms.txt (copy it)');
+
+const claudeVersion = read('CLAUDE.md').match(/\*\*Current version:\*\* (\S+)/)?.[1];
+if (claudeVersion !== version) problems.push(`CLAUDE.md says "Current version: ${claudeVersion}"`);
+
+if (!read('CHANGELOG.md').includes(`## [${version}]`)) problems.push(`CHANGELOG.md has no "## [${version}]" entry`);
+
+if (problems.length > 0) {
+    console.error(`check:versions: package.json is ${version}, but:\n  - ${problems.join('\n  - ')}`);
+    process.exit(1);
+}
+console.log(`check:versions: ${version} everywhere (llms.txt, demo copy, CLAUDE.md, CHANGELOG)`);

From a4ea746f8895d4dbd31272d6a6240bf4463229e8 Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 18:15:01 +0530
Subject: [PATCH 14/18] docs: tidy CLAUDE.md and CHANGELOG after rebasing onto
 3.9.1

---
 CHANGELOG.md | 9 ---------
 CLAUDE.md    | 9 +++++----
 2 files changed, 5 insertions(+), 13 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 544afe43..0b138259 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,21 +11,12 @@
 
 - **The AI assistant's change chips are translatable**: 11 `localeText` keys (`aiAssistantChipSearch`, `ChipNoFilters`, `ChipSort(field, direction)`, `ChipNoSorting`, `ChipGroup`, `ChipNoGrouping`, `ChipSummary(fn, field)`, `ChipNoSummaries`, `ChipPivot`, `ChipNoPivot`, `ChipVisibility(field, visible)`), English by default; filter operators in the chips use `filterPanelOperators`. `buildAiChips` takes the texts as an optional third argument.
 
-### Fixed
-
-- **The npm package no longer ships the theme test baselines.** `lib/theme/__baseline__/` (three JSON files, 1.6 MB, added in 3.7.0) was published because `files` excluded tests, screenshots and snapshots but not that folder. Unpacked size 9.4 → 7.8 MB.
-
-### Documentation
-
-- Roadmap brought up to date (3.5.1 – 3.9.0 shipped; new features on hold until the backlog of fixes, refactors and performance work is closed; the 4.0 feature-modules proposal). `docs/performance.md`: edit, paste, fill, group toggle and select-all timings at 1M rows (3.8 – 3.9) and how to keep rows memoised. README: spreadsheet editing, Ask AI panel, `localeText`, RTL and Theme Builder in the feature list; bundle sizes re-measured (an app using only `DataGrid` ships 87 KB gzipped). `rtl.md` and `components/pagination.md` no longer say their labels cannot be translated. Architecture notes on the column-window memo and the locale context. The wiki home links the newer feature pages.
-
 ### Internal
 
 - **Guards that fail CI instead of relying on review:** `check:versions` (llms.txt, CLAUDE.md and CHANGELOG must name the `package.json` version), `check:compiler` (fails on any React Compiler result outside a short allowlist, so a refactor that stops it from optimizing a hook is caught), ESLint `no-explicit-any` turned on (it was off; the library had none, the demo's 9 are now typed) and a rule against new `as unknown as` casts in the library outside the documented boundary files.
 
 - **Refactors (no behaviour change):** `GridToolbar` split into its panels with a shared `useToolbarPanel` (861 → 492 lines); the exporters one file per format; the xlsx cell helpers in `excelCells`; the cell range drag in `useGridCellRangeDrag`; keydown handling in a plain, unit-tested `handleGridKeyDown` function. Type casts `as unknown as` 43 → 15: the row pipeline, data source and grouping are called with the row type, the row store is typed with it, and column widenings go through one documented helper. DOM, styles and behaviour unchanged (theme baseline and the full browser suite in three engines).
 
-- **`npm run check:package`**, run in CI and before publishing: fails when `npm pack` would ship a test artifact (the gap that let the theme baselines through) or when `DataGrid` alone, minified and gzipped, grows past its budget (92 KB; 85.5 KB now).
 - Demo: the source viewer on every example page is syntax-highlighted (`CodeBlock`, prism-react-renderer), and the integration tips under it are corrected (inline columns and handlers are fine since 3.0 / 3.9; replace rows instead of mutating them).
 
 ---
diff --git a/CLAUDE.md b/CLAUDE.md
index e1a00b63..c21ada1b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -157,16 +157,17 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs`
 
 ---
 
-## v3.9.1 — significant changes
-
-- **`npm run check:package`** (after `build:lib`; in `ci.yml` and `npm-publish.yml`): fails if `npm pack` would ship a test artifact (`*.test.*`, `__baseline__`, `__screenshots__`, `__snapshots__`, `__fixtures__`) and if `DataGrid` alone, minified with esbuild and gzipped, exceeds `DATAGRID_GZIP_BUDGET` (92 KB; 85.5 KB at 3.9.0). `files` in package.json works by exclusion: add a negation for any new kind of test folder under `lib/`. Raise the budget only in the commit that needs it
 ## v3.10.0 — significant changes (unreleased)
-- R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
+
 - **CI guards** (each fails the build): `check:versions` (`llms.txt` + its demo copy, CLAUDE.md "Current version" and a CHANGELOG entry must match `package.json`; bump them together), `check:compiler` (React Compiler: only the allowlisted `EXPECTED` results in `scripts/check-react-compiler.mjs`), `check:package` (no test artifacts, DataGrid-only size budget), ESLint `no-explicit-any` (now `error`, also in the demo) and `no-restricted-syntax` against `as unknown as` in `lib/` outside the boundary files listed in `eslint.config.js`
 - **Types (R4)**: a `GridColDef` passed to an untyped public surface (callback params, toolbar, store) goes through `untypedColumn` / `untypedColumns` (`lib/utils/untypedColumns.ts`), the one place that widening is written; narrowing untyped → `GridColDef` / `R[]` is a plain `as`. `useDataGrid` types `setRows` / `replaceRows` / `rowsLookup` with `R`; call generic hooks with `` explicitly when an untyped argument would otherwise infer `GridRowModel`. Remaining `as unknown as` (15) are synthetic rows, third-party module typings, `window` / CSS custom properties: do not add new ones elsewhere
 - R5 splits (no behaviour change): `GridToolbar` panels in `AggregationPanel` / `ColumnsPanelWrapper` / `FilterPanelWrapper` with shared `useToolbarPanel` (portal, position, Escape / outside press; `onClose` through a ref); exporters one file per format (`exportCsv`, `exportExcelHtml`, `exportJson`, `printGrid`, shared `exportRows`; `utils/export/index.ts` only re-exports); xlsx helpers in `excelCells.ts`; the range drag in `useGridCellRangeDrag`, called from inside `useGridCellSelection` (hook order unchanged); keydown handling in `handleGridKeyDown(event, context)` (`hooks/core/gridKeyDown.ts`, a plain function over a typed `GridKeyDownContext`, unit-testable without rendering; `useGridKeyboardNavigation` calls it from a `useCallback` with the same dependencies). A hook that takes refs as parameters must list them in its dependency arrays, or React Compiler skips it (`PreserveManualMemo`)
 - Demo code samples go through `demo/components/CodeBlock.tsx` (prism-react-renderer, demo-only devDependency); live output panels (JSON state, logs) stay plain `
`
 
+## v3.9.1 — significant changes
+
+- **`npm run check:package`** (after `build:lib`; in `ci.yml` and `npm-publish.yml`): fails if `npm pack` would ship a test artifact (`*.test.*`, `__baseline__`, `__screenshots__`, `__snapshots__`, `__fixtures__`) and if `DataGrid` alone, minified with esbuild and gzipped, exceeds `DATAGRID_GZIP_BUDGET` (92 KB; 85.5 KB at 3.9.0). `files` in package.json works by exclusion: add a negation for any new kind of test folder under `lib/`. Raise the budget only in the commit that needs it
+
 ## v3.9.0 — significant changes
 
 - **Rows stay memoised.** Every prop passed to `Row` must keep its identity unless that row changed: handlers go through the latest-ref pattern (`useGridRowInteractions`: stable `handleRowClick` / `handleSelectionChange`), per-row state is passed only to the row it concerns (`editingCell`, `focusedCellField`, `cellRange`), and `useGridVirtualization` builds `virtualColumns` in its own memo over column inputs only. `DataGrid.rowMemo.test.tsx` counts `Row` renders through `isCellEditable` (`Cell` is memoised, so `renderCell` would not show them)

From e044823e93674f0da51d2492622fed3fe772222c Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 18:27:21 +0530
Subject: [PATCH 15/18] perf(selection): build the select-all set once and
 reuse it

The set built by nextSelectionForRows is reused when its model array comes
back as rowSelectionModel, and a select-all marks it as containing the
selectable ids so the all-selected count is skipped. Select all at 1M rows
~260 -> ~130 ms; selection sets are ReadonlySet internally
---
 CHANGELOG.md                                  |  4 ++
 docs/performance.md                           |  2 +-
 .../plans/2026-10-13-audit-tracker.md         |  1 +
 lib/components/DataGrid/GridListView.tsx      |  2 +-
 lib/components/DataGrid/GridPinnedRows.tsx    |  2 +-
 lib/components/DataGrid/GridVirtualRows.tsx   |  2 +-
 lib/hooks/core/useGridApiMethods.ts           |  4 +-
 lib/hooks/core/useGridControlledState.ts      |  5 ++-
 lib/hooks/core/useGridKeyboardNavigation.ts   |  2 +-
 lib/hooks/core/useGridLiveRowSelection.ts     |  4 +-
 lib/hooks/core/useGridRowInteractions.ts      |  2 +-
 lib/hooks/core/useGridRowSelection.ts         | 13 ++++++-
 lib/utils/selectionSets.test.ts               | 37 +++++++++++++++++++
 lib/utils/selectionSets.ts                    | 36 ++++++++++++++++++
 14 files changed, 102 insertions(+), 14 deletions(-)
 create mode 100644 lib/utils/selectionSets.test.ts
 create mode 100644 lib/utils/selectionSets.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b138259..73d11596 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@
 
 - **The AI assistant's change chips are translatable**: 11 `localeText` keys (`aiAssistantChipSearch`, `ChipNoFilters`, `ChipSort(field, direction)`, `ChipNoSorting`, `ChipGroup`, `ChipNoGrouping`, `ChipSummary(fn, field)`, `ChipNoSummaries`, `ChipPivot`, `ChipNoPivot`, `ChipVisibility(field, visible)`), English by default; filter operators in the chips use `filterPanelOperators`. `buildAiChips` takes the texts as an optional third argument.
 
+### Performance
+
+- **Select all at 1,000,000 rows takes ~130 ms instead of ~260 ms** (deselect ~95 instead of ~120), with no API change. The selection set built for the new model is reused when that array comes back as `rowSelectionModel` (instead of `new Set(model)` again), and a set built by selecting all rows is known to contain them, so the header checkbox's "all selected" count is skipped. The shared selection set is typed `ReadonlySet` internally, so nothing can change it in place.
+
 ### Internal
 
 - **Guards that fail CI instead of relying on review:** `check:versions` (llms.txt, CLAUDE.md and CHANGELOG must name the `package.json` version), `check:compiler` (fails on any React Compiler result outside a short allowlist, so a refactor that stops it from optimizing a hook is caught), ESLint `no-explicit-any` turned on (it was off; the library had none, the demo's 9 are now typed) and a rule against new `as unknown as` casts in the library outside the documented boundary files.
diff --git a/docs/performance.md b/docs/performance.md
index 86089b4d..4ce474d2 100644
--- a/docs/performance.md
+++ b/docs/performance.md
@@ -174,7 +174,7 @@ Measured in Chromium on an Apple M1, 1,000,000 rows, from the event to the end o
 | Fill 1,000 cells | 809 ms | ~50 ms | |
 | Expand a group | 279 ms | ~135 ms | leaf row metadata built once per tree |
 | Collapse a group | ~120 ms | ~1 ms | |
-| Select all | ~270 ms | ~270 ms | bounded by building 1M-id sets for the array `rowSelectionModel` |
+| Select all | ~270 ms | ~270 ms (~130 ms in 3.10) | 3.10 builds the 1M-id set once and reuses it for the model it returns; the rest is the live-selection check |
 
 3.9.0 keeps rows memoised: selecting a row, starting or committing an edit, and vertical scrolling re-render only the rows they change (a click re-rendered every visible row, now one). React's own work per interaction is about 3–5 ms at 100k rows.
 
diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md
index 3aca99cd..fe512ca6 100644
--- a/docs/superpowers/plans/2026-10-13-audit-tracker.md
+++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md
@@ -77,3 +77,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 - 2026-10-13 — R5 and R6 done (all engines green, compiler baseline). Next: R4 casts.
 - 2026-10-13 — R4 done (43 → 15 casts). All R items done.
 - 2026-10-13 — Insight follow-ups as CI guards: check:versions, check:compiler allowlist (verified it fails on a reintroduced PreserveManualMemo), ESLint no-explicit-any on (demo's 9 `any` typed), no `as unknown as` outside boundary files (verified with a probe).
+- 2026-10-13 — Select-all at 1M: profiled (live check 75, all-selected count 73, Set from model 62, next model 55, GC 48 ms); no API change needed after all: set built once and reused (WeakMap by model array), select-all marks the set as containing the selectable ids. ~260 → ~130 ms. Live check (54 ms) left.
diff --git a/lib/components/DataGrid/GridListView.tsx b/lib/components/DataGrid/GridListView.tsx
index deff9684..2e27b390 100644
--- a/lib/components/DataGrid/GridListView.tsx
+++ b/lib/components/DataGrid/GridListView.tsx
@@ -35,7 +35,7 @@ export interface GridListViewProps {
     /** The page actually shown (the requested page clamped to the last page). */
     currentPage: number;
     pageSizeOptions: number[];
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     listViewColumn: GridListViewColDef;
     /** Grid columns by field; a list field that names one gets its valueGetter / valueFormatter. */
     columnsByField: ReadonlyMap;
diff --git a/lib/components/DataGrid/GridPinnedRows.tsx b/lib/components/DataGrid/GridPinnedRows.tsx
index 0ebd035d..b5383d7e 100644
--- a/lib/components/DataGrid/GridPinnedRows.tsx
+++ b/lib/components/DataGrid/GridPinnedRows.tsx
@@ -31,7 +31,7 @@ export interface GridPinnedRowsProps {
     rows: R[];
     position: 'top' | 'bottom';
     columns: GridColDef[];
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     checkboxSelection: boolean;
     onRowClick: (params: GridRowParams) => void;
     onRowDoubleClick?: (params: GridRowParams) => void;
diff --git a/lib/components/DataGrid/GridVirtualRows.tsx b/lib/components/DataGrid/GridVirtualRows.tsx
index c4e2d724..30965125 100644
--- a/lib/components/DataGrid/GridVirtualRows.tsx
+++ b/lib/components/DataGrid/GridVirtualRows.tsx
@@ -41,7 +41,7 @@ export interface GridVirtualRowsProps {
     hasDetailPanel: boolean;
     rowReordering: boolean;
     rowHeight: number;
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     onRowClick: (params: GridRowParams) => void;
     onRowDoubleClick?: (params: GridRowParams) => void;
     onCellClick: (params: GridCellParams) => void;
diff --git a/lib/hooks/core/useGridApiMethods.ts b/lib/hooks/core/useGridApiMethods.ts
index 0996f5fa..3c27c192 100644
--- a/lib/hooks/core/useGridApiMethods.ts
+++ b/lib/hooks/core/useGridApiMethods.ts
@@ -29,7 +29,7 @@ export interface UseGridApiMethodsParams {
     paginationModel: GridPaginationModel;
     onPaginationModelChange: (model: GridPaginationModel) => void;
 
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     /**
      * A selection change made since the last commit (see useGridControlledState), or null. It
      * wins over `selectedRowIds`, so the API is current inside onRowSelectionModelChange.
@@ -103,7 +103,7 @@ export function useGridApiMethods(params: UseGridApiMethodsParams): void {
         api.setPage = (page) => setPaginationModel({ ...latest().paginationModel, page });
         api.setPageSize = (pageSize) => setPaginationModel({ ...latest().paginationModel, pageSize, page: 0 });
 
-        const currentSelection = (): Set => {
+        const currentSelection = (): ReadonlySet => {
             const pending = latest().getPendingRowSelectionModel?.();
             return pending ? new Set(pending) : latest().selectedRowIds;
         };
diff --git a/lib/hooks/core/useGridControlledState.ts b/lib/hooks/core/useGridControlledState.ts
index c6b6f84d..52519582 100644
--- a/lib/hooks/core/useGridControlledState.ts
+++ b/lib/hooks/core/useGridControlledState.ts
@@ -14,6 +14,7 @@ import type {
 } from '../../types';
 import type { GridInitialState, GridDensityState } from '../../state/types';
 import { EMPTY_ROW_GROUPING_MODEL } from '../../utils/gridModes';
+import { selectionSetOf } from '../../utils/selectionSets';
 
 export type GridDensity = GridDensityState['density'];
 
@@ -96,7 +97,7 @@ export interface UseGridControlledStateReturn {
 
     // selection
     rowSelectionModel: GridRowId[];
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     isSelectionControlled: boolean;
     setInternalRowSelectionModel: React.Dispatch>;
     /** Sets the internal model when uncontrolled and fires onRowSelectionModelChange. */
@@ -267,7 +268,7 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
     const isSelectionControlled = propRowSelectionModel !== undefined;
     const [internalRowSelectionModel, setInternalRowSelectionModel] = useState([]);
     const rowSelectionModel = isSelectionControlled ? propRowSelectionModel! : internalRowSelectionModel;
-    const selectedRowIds = useMemo(() => new Set(rowSelectionModel), [rowSelectionModel]);
+    const selectedRowIds = useMemo(() => selectionSetOf(rowSelectionModel), [rowSelectionModel]);
 
     // Cleared on every commit: the rendered model is current again (a controlled parent may
     // also have rejected the change).
diff --git a/lib/hooks/core/useGridKeyboardNavigation.ts b/lib/hooks/core/useGridKeyboardNavigation.ts
index e09aa170..fac7d069 100644
--- a/lib/hooks/core/useGridKeyboardNavigation.ts
+++ b/lib/hooks/core/useGridKeyboardNavigation.ts
@@ -55,7 +55,7 @@ export interface UseGridKeyboardNavigationParams {
     /** Focusable columns in render order: system columns first, then the visible data columns. */
     navigationColumns: ReadonlyArray>;
     checkboxSelection: boolean;
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     handleSelectionChange: (id: GridRowId, selected: boolean) => void;
     handleDetailPanelToggle: (id: GridRowId) => void;
     editingHandlers: {
diff --git a/lib/hooks/core/useGridLiveRowSelection.ts b/lib/hooks/core/useGridLiveRowSelection.ts
index 8fd24fc4..6883abde 100644
--- a/lib/hooks/core/useGridLiveRowSelection.ts
+++ b/lib/hooks/core/useGridLiveRowSelection.ts
@@ -3,7 +3,7 @@ import type { GridRowId, GridRowMeta, GridRowModel } from '../../types';
 
 export interface UseGridLiveRowSelectionParams {
     /** The selection model (controlled prop or internal state). */
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     /** The row store: ids of the rows the grid holds. */
     rowsLookup: ReadonlyMap;
     /** Hierarchy rows the grid made (group rows, generated tree parents); their ids are kept. */
@@ -32,7 +32,7 @@ const idsKey = (ids: Iterable): string =>
  * own state that re-adds them) is not notified again and cannot loop, and StrictMode's re-run of
  * the effect does not report twice.
  */
-export function useGridLiveRowSelection(params: UseGridLiveRowSelectionParams): Set {
+export function useGridLiveRowSelection(params: UseGridLiveRowSelectionParams): ReadonlySet {
     const { selectedRowIds, rowsLookup, rowMetaMap, enabled, onSelectionModelChange } = params;
 
     const liveRowIds = useMemo(() => {
diff --git a/lib/hooks/core/useGridRowInteractions.ts b/lib/hooks/core/useGridRowInteractions.ts
index 39273103..b0f7bfb4 100644
--- a/lib/hooks/core/useGridRowInteractions.ts
+++ b/lib/hooks/core/useGridRowInteractions.ts
@@ -10,7 +10,7 @@ export interface UseGridRowInteractionsParams {
     dataRows: R[];
     getRowId: (row: GridRowModel) => GridRowId;
     isPivotActive: boolean;
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     onSelectionModelChange: (model: GridRowId[]) => void;
     disableMultipleRowSelection: boolean;
     disableRowSelectionOnClick: boolean;
diff --git a/lib/hooks/core/useGridRowSelection.ts b/lib/hooks/core/useGridRowSelection.ts
index a2fd203c..1eb58c01 100644
--- a/lib/hooks/core/useGridRowSelection.ts
+++ b/lib/hooks/core/useGridRowSelection.ts
@@ -1,5 +1,6 @@
 import { useCallback, useMemo } from 'react';
 import type { GridRowId } from '../../types';
+import { isKnownToContainAll, markContainsAll, rememberSelectionSet } from '../../utils/selectionSets';
 
 // ── Pure selection transitions (shared by the UI handlers and the imperative API) ──────────
 
@@ -15,7 +16,12 @@ export function nextSelectionForRows(
     }
     const next = new Set(current);
     ids.forEach(id => (isSelected ? next.add(id) : next.delete(id)));
-    return Array.from(next);
+    const model = Array.from(next);
+    // The model's set is `next`: reused when this array comes back as rowSelectionModel, and known to
+    // contain `ids` when they were selected (select-all then skips counting them again).
+    rememberSelectionSet(model, next);
+    if (isSelected) markContainsAll(next, ids);
+    return model;
 }
 
 /** A row click toggles the row; with `single` it replaces the selection instead of adding to it. */
@@ -30,7 +36,7 @@ export function isSameSelection(current: ReadonlySet, next: readonly
 }
 
 export interface UseGridRowSelectionParams {
-    selectedRowIds: Set;
+    selectedRowIds: ReadonlySet;
     /** Applies a new model: internal state when uncontrolled, plus onRowSelectionModelChange. */
     onSelectionModelChange: (model: GridRowId[]) => void;
     disableMultipleRowSelection: boolean;
@@ -75,6 +81,9 @@ export function useGridRowSelection(params: UseGridRowSelectionParams): UseGridR
     }, [commit, selectedRowIds, selectableRowIds]);
 
     const { allSelected, someSelected } = useMemo(() => {
+        if (selectableRowIds.length > 0 && isKnownToContainAll(selectedRowIds, selectableRowIds)) {
+            return { allSelected: true, someSelected: false };
+        }
         let selectedCount = 0;
         for (const id of selectableRowIds) if (selectedRowIds.has(id)) selectedCount++;
         const all = selectableRowIds.length > 0 && selectedCount === selectableRowIds.length;
diff --git a/lib/utils/selectionSets.test.ts b/lib/utils/selectionSets.test.ts
new file mode 100644
index 00000000..2dcfe055
--- /dev/null
+++ b/lib/utils/selectionSets.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from 'vitest';
+import { isKnownToContainAll, markContainsAll, rememberSelectionSet, selectionSetOf } from './selectionSets';
+import { nextSelectionForRows } from '../hooks/core/useGridRowSelection';
+
+describe('selection sets', () => {
+    it('reuses the set built for a model array, and only for that array', () => {
+        const model = [1, 2];
+        const set = new Set(model);
+        rememberSelectionSet(model, set);
+        expect(selectionSetOf(model)).toBe(set);
+        const copy = [1, 2];
+        expect(selectionSetOf(copy)).not.toBe(set);
+        expect([...selectionSetOf(copy)]).toEqual([1, 2]);
+        expect(selectionSetOf(copy)).toBe(selectionSetOf(copy));
+    });
+
+    it('knows a set contains all ids only for the exact ids array it was marked with', () => {
+        const ids = [1, 2, 3];
+        const set = new Set(ids);
+        markContainsAll(set, ids);
+        expect(isKnownToContainAll(set, ids)).toBe(true);
+        expect(isKnownToContainAll(set, [1, 2, 3])).toBe(false);
+        expect(isKnownToContainAll(new Set(ids), ids)).toBe(false);
+    });
+
+    it('nextSelectionForRows hands its set over to the model and marks a select-all', () => {
+        const selectable = [1, 2, 3];
+        const model = nextSelectionForRows(new Set([9]), selectable, true, false);
+        expect(model).toEqual([9, 1, 2, 3]);
+        const set = selectionSetOf(model);
+        expect([...set]).toEqual([9, 1, 2, 3]);
+        expect(isKnownToContainAll(set, selectable)).toBe(true);
+        const cleared = nextSelectionForRows(set, selectable, false, false);
+        expect(cleared).toEqual([9]);
+        expect(isKnownToContainAll(selectionSetOf(cleared), selectable)).toBe(false);
+    });
+});
diff --git a/lib/utils/selectionSets.ts b/lib/utils/selectionSets.ts
new file mode 100644
index 00000000..fe04c580
--- /dev/null
+++ b/lib/utils/selectionSets.ts
@@ -0,0 +1,36 @@
+import type { GridRowId } from '../types';
+
+// Select-all at 1M rows built the same 1M-id set three or four times: once to compute the next
+// model, once more from that model array (`new Set(rowSelectionModel)`), and a full count to decide
+// "all selected". These weak caches let the set built for a model be reused when that very array
+// comes back as `rowSelectionModel`, and remember that it contains a given list of ids. Keyed by
+// identity, so they never apply to an array or set the grid did not build; entries are collected
+// with their keys.
+
+const setByModel = new WeakMap>();
+const containsAll = new WeakMap, readonly GridRowId[]>();
+
+/** The ids of `model` as a set: the one the grid built for this array, or a new one. */
+export function selectionSetOf(model: readonly GridRowId[]): ReadonlySet {
+    let set = setByModel.get(model);
+    if (!set) {
+        set = new Set(model);
+        setByModel.set(model, set);
+    }
+    return set;
+}
+
+/** Records that `set` holds exactly the ids of `model` (the grid built both). */
+export function rememberSelectionSet(model: readonly GridRowId[], set: ReadonlySet): void {
+    setByModel.set(model, set);
+}
+
+/** Records that `set` contains every id of `ids` (it was built by selecting them). */
+export function markContainsAll(set: ReadonlySet, ids: readonly GridRowId[]): void {
+    containsAll.set(set, ids);
+}
+
+/** Whether `set` is known to contain every id of this very `ids` array. False when unknown. */
+export function isKnownToContainAll(set: ReadonlySet, ids: readonly GridRowId[]): boolean {
+    return containsAll.get(set) === ids;
+}

From 71247ba98c72ba23534799f57ca977d3ace1ba43 Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 18:43:19 +0530
Subject: [PATCH 16/18] docs: close the time-sliced pipeline; archive its
 branch

---
 CLAUDE.md                                             |  2 +-
 docs/roadmap.md                                       |  2 +-
 docs/superpowers/plans/2026-10-13-audit-tracker.md    |  1 +
 .../specs/2026-10-12-nonblocking-pipeline-proposal.md | 11 ++++++++++-
 4 files changed, 13 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c21ada1b..2aa0af98 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -200,7 +200,7 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs`
 - **RTL**: read/write horizontal scroll only through `getScrollStart` / `setScrollStart` (`lib/utils/direction.ts`; scroll state is the offset from the inline start, never negative); map arrows with `logicalArrowKey(key, isRtl(el))`; panels with `getPanelPosition`. `pinnedColumns.left` / `right` are logical (inline start / end): sticky offsets use `insetInlineStart` / `insetInlineEnd` (jsdom: `style.getPropertyValue('inset-inline-start')`), borders `border-inline-*`, box-shadow x-offsets need `:dir(rtl)` mirrors. The column menu pins by screen side. `useGridScrollSync` observes `dir` mutations
 - Testing RTL: Playwright's headless Chromium runs with `--hide-scrollbars`; with classic scrollbars and `scrollbar-gutter: stable`, RTL cannot reach `scrollLeft` 0 — compare against the scroll origin. Reproduce on macOS with `defaults write chrome-headless-shell AppleShowScrollBars Always`. The grid's `scrollbar-width` / `scrollbar-color` disable `::-webkit-scrollbar` in Chromium
 - Header filter strings are in `localeText` (`headerFilter*`, resolved in `lib/utils/headerFilterLocale.ts`)
-- Not in this release: the time-sliced pipeline (Option A of `docs/superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md`) is held (backlog) until its overhead is within ~20 %
+- The time-sliced pipeline (Option A of `docs/superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md`) is closed: 1.5–2× slower in total; branch `archive/timesliced-pipeline-2026-10-12` kept for reference
 
 ## v3.5.1 — significant changes
 
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 35665380..7cf3324e 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -101,7 +101,7 @@ This document tracks the current status of features in `OpenGridX` and outlines
 New features (the *Next* list below) wait until the open fixes, refactors and performance work are done. Tracked in [`superpowers/plans/2026-10-13-audit-tracker.md`](superpowers/plans/2026-10-13-audit-tracker.md):
 
 *   **Refactors** (no behaviour change): about 40 `as unknown as` casts (R4), files over 600 lines such as `GridToolbar.tsx` (R5), hooks with very wide parameter lists such as keyboard navigation (R6).
-*   **Performance**: the time-sliced filter / sort pipeline (held: 1.5–2× slower in total, needs to be within ~20 %); select-all at 1M rows (~270 ms, bounded by the array `rowSelectionModel`).
+*   **Performance**: ✅ select-all at 1M rows ~260 → ~130 ms (3.10, no API change). ❌ The time-sliced filter / sort pipeline is closed: it was 1.5–2× slower in total (target ≤ 20 %) and the remaining 1M-row sort pause is better served by a `dataSource`; see the [proposal's status](superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md).
 *   **Demo**: syntax highlighting in the source viewer.
 *   **Bundle size (4.0)**: an app using only `DataGrid` ships 87 KB gzipped; opt-in [feature modules](superpowers/specs/2026-10-13-feature-modules-proposal.md) would save ~20 KB. Breaking, so planned for 4.0.
 
diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md
index fe512ca6..e9b78879 100644
--- a/docs/superpowers/plans/2026-10-13-audit-tracker.md
+++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md
@@ -78,3 +78,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 - 2026-10-13 — R4 done (43 → 15 casts). All R items done.
 - 2026-10-13 — Insight follow-ups as CI guards: check:versions, check:compiler allowlist (verified it fails on a reintroduced PreserveManualMemo), ESLint no-explicit-any on (demo's 9 `any` typed), no `as unknown as` outside boundary files (verified with a probe).
 - 2026-10-13 — Select-all at 1M: profiled (live check 75, all-selected count 73, Set from model 62, next model 55, GC 48 ms); no API change needed after all: set built once and reused (WeakMap by model array), select-all marks the set as containing the selectable ids. ~260 → ~130 ms. Live check (54 ms) left.
+- 2026-10-13 — Time-sliced pipeline closed (user agreed): proposal status updated, branch archived as `archive/timesliced-pipeline-2026-10-12`, both agent worktrees removed. Backlog under the feature hold is now empty except R-items done; ready to lift the hold (ask user).
diff --git a/docs/superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md b/docs/superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md
index d3938372..d9aa5ee2 100644
--- a/docs/superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md
+++ b/docs/superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md
@@ -1,6 +1,15 @@
 # Proposal: a non-blocking row pipeline (phase 2)
 
-Status: proposal only, nothing implemented. Follows the phase 1 work (synchronous, algorithmic) from
+Status: **closed, not shipping (2026-10-13).** Option A was implemented on a branch (kept as
+`archive/timesliced-pipeline-2026-10-12`, based on 3.6.0) and measured 1.5–2× slower in total than the
+synchronous pipeline, against a target of ≤ 20 %. Since then sorting became 3–5× faster (~0.4–0.5 s at
+1M rows, most of it a single native `sort()` that cannot be split), and edits, paste, fill and select-all
+no longer block, so the remaining pause is one sort or filter of 1M rows client-side, for which the docs
+recommend a `dataSource`. A Web Worker cannot run consumer `valueGetter` / `sortComparator` functions,
+and React transitions cannot interrupt a synchronous computation. Revisit only with a design that keeps
+the native sort whole (for example sorting off-thread on extracted primitive keys) and meets the target.
+
+Original status: proposal only, nothing implemented. Follows the phase 1 work (synchronous, algorithmic) from
 2026-10-12, measured with `npm run bench` (see `docs/performance.md` → Benchmarks).
 
 ## Why

From 5f3afc5eb90c1e3aa4ea57745cbc63b133c8728b Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 18:43:30 +0530
Subject: [PATCH 17/18] docs(roadmap): mark the 3.10 backlog items done

---
 docs/roadmap.md | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/docs/roadmap.md b/docs/roadmap.md
index 7cf3324e..72b5ea4e 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -96,13 +96,13 @@ This document tracks the current status of features in `OpenGridX` and outlines
 | 3.10.0 | open | AI change chips translatable | Backlog work below |
 | Freeze | 19 Dec – 5 Jan | Bug fixes only | — |
 
-### Now: backlog first (feature hold since 13 Oct)
+### Now: backlog first (feature hold since 13 Oct; backlog cleared in 3.10)
 
 New features (the *Next* list below) wait until the open fixes, refactors and performance work are done. Tracked in [`superpowers/plans/2026-10-13-audit-tracker.md`](superpowers/plans/2026-10-13-audit-tracker.md):
 
-*   **Refactors** (no behaviour change): about 40 `as unknown as` casts (R4), files over 600 lines such as `GridToolbar.tsx` (R5), hooks with very wide parameter lists such as keyboard navigation (R6).
+*   ✅ **Refactors** (no behaviour change, 3.10): `as unknown as` casts 43 → 15 (R4), files over 600 lines split (R5), keydown handling as a typed, tested function (R6); CI guards against regressions (`check:compiler`, `check:versions`, ESLint `no-explicit-any` and no new `as unknown as`).
 *   **Performance**: ✅ select-all at 1M rows ~260 → ~130 ms (3.10, no API change). ❌ The time-sliced filter / sort pipeline is closed: it was 1.5–2× slower in total (target ≤ 20 %) and the remaining 1M-row sort pause is better served by a `dataSource`; see the [proposal's status](superpowers/specs/2026-10-12-nonblocking-pipeline-proposal.md).
-*   **Demo**: syntax highlighting in the source viewer.
+*   ✅ **Demo**: syntax highlighting in the source viewer (3.10).
 *   **Bundle size (4.0)**: an app using only `DataGrid` ships 87 KB gzipped; opt-in [feature modules](superpowers/specs/2026-10-13-feature-modules-proposal.md) would save ~20 KB. Breaking, so planned for 4.0.
 
 ### Across the quarter

From 8283d6489535ff09dd6961cb04f0c090368a838e Mon Sep 17 00:00:00 2001
From: Asif Ansari 
Date: Tue, 13 Oct 2026 18:48:53 +0530
Subject: [PATCH 18/18] chore(release): 3.10.0

---
 CHANGELOG.md                                       | 2 +-
 CLAUDE.md                                          | 4 ++--
 demo/public/llms.txt                               | 4 +++-
 docs/API_REFERENCE.md                              | 4 ++++
 docs/features/ai-toolkit.md                        | 1 +
 docs/roadmap.md                                    | 2 +-
 docs/superpowers/plans/2026-10-13-audit-tracker.md | 1 +
 llms.txt                                           | 4 +++-
 package-lock.json                                  | 4 ++--
 package.json                                       | 2 +-
 10 files changed, 19 insertions(+), 9 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73d11596..787982cd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
 
 ---
 
-## [Unreleased — 3.10.0]
+## [3.10.0] — 2026-10-13
 
 ### Added
 
diff --git a/CLAUDE.md b/CLAUDE.md
index 2aa0af98..d0bcd1c0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,7 +14,7 @@ This file is auto-loaded by Claude Code and other AI coding assistants. It provi
 - **Build:** `npm run build:lib` produces the published `dist/opengridx.es.js` / `dist/opengridx.umd.js`. `npm run build` type-checks the lib and builds the **demo site**.
 - **Published:** yes — on npm as `@opencorestack/opengridx` with external consumers. Treat public types and behaviour changes as semver-relevant.
 - **Lint:** `npm run lint` (ESLint). Must pass before every commit.
-- **Current version:** 3.9.1
+- **Current version:** 3.10.0
 
 ---
 
@@ -157,7 +157,7 @@ The GitHub wiki is generated from `docs/` + `wiki/` by `scripts/build-wiki.mjs`
 
 ---
 
-## v3.10.0 — significant changes (unreleased)
+## v3.10.0 — significant changes
 
 - **CI guards** (each fails the build): `check:versions` (`llms.txt` + its demo copy, CLAUDE.md "Current version" and a CHANGELOG entry must match `package.json`; bump them together), `check:compiler` (React Compiler: only the allowlisted `EXPECTED` results in `scripts/check-react-compiler.mjs`), `check:package` (no test artifacts, DataGrid-only size budget), ESLint `no-explicit-any` (now `error`, also in the demo) and `no-restricted-syntax` against `as unknown as` in `lib/` outside the boundary files listed in `eslint.config.js`
 - **Types (R4)**: a `GridColDef` passed to an untyped public surface (callback params, toolbar, store) goes through `untypedColumn` / `untypedColumns` (`lib/utils/untypedColumns.ts`), the one place that widening is written; narrowing untyped → `GridColDef` / `R[]` is a plain `as`. `useDataGrid` types `setRows` / `replaceRows` / `rowsLookup` with `R`; call generic hooks with `` explicitly when an untyped argument would otherwise infer `GridRowModel`. Remaining `as unknown as` (15) are synthetic rows, third-party module typings, `window` / CSS custom properties: do not add new ones elsewhere
diff --git a/demo/public/llms.txt b/demo/public/llms.txt
index 5ed8822e..ba2ea0b0 100644
--- a/demo/public/llms.txt
+++ b/demo/public/llms.txt
@@ -1,6 +1,6 @@
 # OpenGridX — AI Agent Context
 # Package: @opencorestack/opengridx
-# Version: 3.9.1
+# Version: 3.10.0
 # Docs: https://opencorestack.github.io/OpenGridX/
 # This file is machine-readable. AI developers: start here.
 
@@ -733,3 +733,5 @@ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, Pa
 25. **Quick filter text** (3.7.1+): besides the raw value and any `valueFormatter` text, `singleSelect` columns match their option label, `boolean` columns Yes / No, and `date` columns the locale date shown. Exports read values through the same `valueGetter` handling as the grid (a throw exports an empty cell), and CSV / HTML-Excel escape formula text from a `valueFormatter` unless it reads as a plain number.
 26. **localeText covers every UI string (3.9+)**: ~200 optional keys (column menu, toolbar, filter / columns / pivot panels, AI panel, pagination, row and header checkboxes, overlays, announcements), English by default. An inline object of strings is fine; define function entries (`rowSelectLabel: id => …`) outside the render so rows stay memoised. Components used outside a grid show English.
 27. **Theme keys added in 3.9**: `grid.checkboxCheckedBackground`, `checkboxCheckmark`, `expandIconColor`, `expandIconHoverColor`; `overlays.itemIndicatorColor` (menu selection dot), `itemSelectedIcon`, `iconColor`, `sectionLabelText`, `dividerColor`. The demo's Theme Builder edits all of them.
+28. **3.10**: the AI assistant's change chips are `localeText` keys too (`aiAssistantChipSort(field, direction)`, `aiAssistantChipSummary(fn, field)`, …). Select-all at 1M rows takes ~130 ms. The library forbids `as unknown as` outside documented boundaries and `any` everywhere (ESLint), and CI checks that `llms.txt`, CLAUDE.md and the CHANGELOG name the `package.json` version.
+29. **Large client-side datasets**: sorting or filtering 1M rows blocks for ~0.4–0.5 s (one native sort). There is no time-sliced mode (measured and rejected: 1.5–2× slower overall). For interactive sorting and filtering at that size use a `dataSource`.
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index baa4d6db..a0a08e94 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -988,6 +988,10 @@ interface GridLocaleText {
   aiAssistantAppliedChanges; aiAssistantRemoveChange(label); aiAssistantUndo; aiAssistantIgnored; aiAssistantIgnoredItem(message);
   aiAssistantEarlier; aiAssistantThinking; aiAssistantStopped; aiAssistantUndone; aiAssistantChangeRemoved;
   aiAssistantApplied(applied, ignored); aiAssistantNotValidated; aiAssistantFailed(message); aiAssistantUnknownError;
+  // AI assistant change chips (v3.10; filter operators in the chips use filterPanelOperators)
+  aiAssistantChipSearch(words); aiAssistantChipNoFilters; aiAssistantChipSort(field, 'asc' | 'desc'); aiAssistantChipNoSorting;
+  aiAssistantChipGroup(field); aiAssistantChipNoGrouping; aiAssistantChipSummary(fn, field); aiAssistantChipNoSummaries;
+  aiAssistantChipPivot(fields); aiAssistantChipNoPivot; aiAssistantChipVisibility(field, visible);
   // Pagination buttons
   paginationLabel; paginationFirstPageLabel; paginationFirstPageTooltip; paginationPreviousPageLabel;
   paginationPreviousPageTooltip; paginationNextPageLabel; paginationNextPageTooltip;
diff --git a/docs/features/ai-toolkit.md b/docs/features/ai-toolkit.md
index 23f155ec..fcfb7e06 100644
--- a/docs/features/ai-toolkit.md
+++ b/docs/features/ai-toolkit.md
@@ -146,6 +146,7 @@ const onPrompt = createGridAiPromptHandler({
 - **Applying.** The reply is applied immediately as one undoable step. Each change shows as a removable chip: a filter item, the quick-filter words, a sort key, a grouping field, an aggregation, a column shown or hidden, the pivot. Removing a chip re-applies the reply without it (a part with no chip left returns to its value from before the reply). **Undo** restores the state from before the reply. This view-state undo is separate from the cell-edit undo history (`undoRedo`).
 - **What is applied.** Each part in `result.state` replaces the grid's model, except column visibility, which is merged over the current model (`{ tax: false }` hides one column and leaves the others). Parts outside `aiAssistant.parts` are ignored. A part the validator emptied (every entry dropped, such as a sort on an unknown field) is not applied, so it cannot clear the grid's model by accident. Changes go through the same handlers as the UI, so `onSortModelChange`, `onRowGroupingModelChange` and the rest fire; with a controlled model, update your state from them.
 - **What was dropped** is listed under the chips, such as `Ignored: Unknown field "revnue"`.
+- **Translating.** Every string of the panel is a `localeText` key (v3.9), including the chips' own texts since v3.10: `aiAssistantChipSort: (field, direction) => \`Tri : ${field}, ${direction === 'desc' ? 'décroissant' : 'croissant'}\``, `aiAssistantChipSummary: (fn, field) => …` (`fn` is the function id, such as `avg`), and the other `aiAssistantChip*` keys in the API reference. Filter operators in the chips use `filterPanelOperators`.
 - **Accessibility.** The panel is a `role="dialog"` labelled "Ask AI". Focus moves to the input when it opens and back to the toolbar button (or wherever it was) when it closes. Escape closes it. The status ("Thinking…", "Applied 3 changes", errors) is announced through the grid's live region.
 - **Events.** `onAiAssistantApply(result)` after a reply was applied; `onAiAssistantError(error)` when `onPrompt` rejects (Stop does not count) or its result was not validated.
 
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 72b5ea4e..226496e8 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -93,7 +93,7 @@ This document tracks the current status of features in `OpenGridX` and outlines
 | **3.8.1** ✅ | shipped 13 Oct | **Grid buttons keep their own styles** (AI panel, toolbar active states) | Exact `bigint` sort; stricter number text; dead-code cleanup; every export documented |
 | **3.9.0** ✅ | shipped 13 Oct | **`localeText` for every UI string** (~200 keys) | 9 theme keys + Theme Builder controls; rows stay memoised through selection and editing; demo sidebar by priority |
 | **3.9.1** ✅ | shipped 13 Oct | **npm package no longer ships 1.6 MB of test baselines** | `check:package` guard in CI; version badges read `package.json`; docs brought up to date |
-| 3.10.0 | open | AI change chips translatable | Backlog work below |
+| **3.10.0** ✅ | shipped 13 Oct | **Backlog cleared**: refactors (R4–R6), CI guards, select-all ~2× faster | AI change chips translatable; demo syntax highlighting |
 | Freeze | 19 Dec – 5 Jan | Bug fixes only | — |
 
 ### Now: backlog first (feature hold since 13 Oct; backlog cleared in 3.10)
diff --git a/docs/superpowers/plans/2026-10-13-audit-tracker.md b/docs/superpowers/plans/2026-10-13-audit-tracker.md
index e9b78879..82595a4a 100644
--- a/docs/superpowers/plans/2026-10-13-audit-tracker.md
+++ b/docs/superpowers/plans/2026-10-13-audit-tracker.md
@@ -79,3 +79,4 @@ Baselines (Chromium, M1, 1M rows): edit 508 ms · paste/undo/redo ~790 ms · fil
 - 2026-10-13 — Insight follow-ups as CI guards: check:versions, check:compiler allowlist (verified it fails on a reintroduced PreserveManualMemo), ESLint no-explicit-any on (demo's 9 `any` typed), no `as unknown as` outside boundary files (verified with a probe).
 - 2026-10-13 — Select-all at 1M: profiled (live check 75, all-selected count 73, Set from model 62, next model 55, GC 48 ms); no API change needed after all: set built once and reused (WeakMap by model array), select-all marks the set as containing the selectable ids. ~260 → ~130 ms. Live check (54 ms) left.
 - 2026-10-13 — Time-sliced pipeline closed (user agreed): proposal status updated, branch archived as `archive/timesliced-pipeline-2026-10-12`, both agent worktrees removed. Backlog under the feature hold is now empty except R-items done; ready to lift the hold (ask user).
+- 2026-10-13 — Release 3.10.0 via PR.
diff --git a/llms.txt b/llms.txt
index 5ed8822e..ba2ea0b0 100644
--- a/llms.txt
+++ b/llms.txt
@@ -1,6 +1,6 @@
 # OpenGridX — AI Agent Context
 # Package: @opencorestack/opengridx
-# Version: 3.9.1
+# Version: 3.10.0
 # Docs: https://opencorestack.github.io/OpenGridX/
 # This file is machine-readable. AI developers: start here.
 
@@ -733,3 +733,5 @@ Arrows move between cells (skipping hidden columns); Home/End, Ctrl+Home/End, Pa
 25. **Quick filter text** (3.7.1+): besides the raw value and any `valueFormatter` text, `singleSelect` columns match their option label, `boolean` columns Yes / No, and `date` columns the locale date shown. Exports read values through the same `valueGetter` handling as the grid (a throw exports an empty cell), and CSV / HTML-Excel escape formula text from a `valueFormatter` unless it reads as a plain number.
 26. **localeText covers every UI string (3.9+)**: ~200 optional keys (column menu, toolbar, filter / columns / pivot panels, AI panel, pagination, row and header checkboxes, overlays, announcements), English by default. An inline object of strings is fine; define function entries (`rowSelectLabel: id => …`) outside the render so rows stay memoised. Components used outside a grid show English.
 27. **Theme keys added in 3.9**: `grid.checkboxCheckedBackground`, `checkboxCheckmark`, `expandIconColor`, `expandIconHoverColor`; `overlays.itemIndicatorColor` (menu selection dot), `itemSelectedIcon`, `iconColor`, `sectionLabelText`, `dividerColor`. The demo's Theme Builder edits all of them.
+28. **3.10**: the AI assistant's change chips are `localeText` keys too (`aiAssistantChipSort(field, direction)`, `aiAssistantChipSummary(fn, field)`, …). Select-all at 1M rows takes ~130 ms. The library forbids `as unknown as` outside documented boundaries and `any` everywhere (ESLint), and CI checks that `llms.txt`, CLAUDE.md and the CHANGELOG name the `package.json` version.
+29. **Large client-side datasets**: sorting or filtering 1M rows blocks for ~0.4–0.5 s (one native sort). There is no time-sliced mode (measured and rejected: 1.5–2× slower overall). For interactive sorting and filtering at that size use a `dataSource`.
diff --git a/package-lock.json b/package-lock.json
index e426139b..7214bdd6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@opencorestack/opengridx",
-  "version": "3.9.1",
+  "version": "3.10.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@opencorestack/opengridx",
-      "version": "3.9.1",
+      "version": "3.10.0",
       "license": "MIT",
       "devDependencies": {
         "@babel/core": "^7.29.7",
diff --git a/package.json b/package.json
index 13d71e25..f69c3fcb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@opencorestack/opengridx",
-  "version": "3.9.1",
+  "version": "3.10.0",
   "description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.",
   "type": "module",
   "main": "./dist/opengridx.umd.js",