@opencorestack/opengridx 3.7.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,9 +19,6 @@
19
19
  border-bottom-left-radius: max(0px, calc(var(--ogx-grid-border-radius, var(--ogx-border-radius-lg)) - var(--ogx-grid-border-width)));
20
20
  border-bottom-right-radius: max(0px, calc(var(--ogx-grid-border-radius, var(--ogx-border-radius-lg)) - var(--ogx-grid-border-width)));
21
21
  }
22
- .ogx:hover {
23
- box-shadow: var(--ogx-shadow-md);
24
- }
25
22
  .ogx__viewport {
26
23
  flex: 1;
27
24
  overflow: auto;
@@ -28,7 +28,7 @@
28
28
  }
29
29
 
30
30
  .ogx-list-view__row:hover {
31
- background: var(--ogx-color-gray-50, #f8fafc);
31
+ background: var(--ogx-grid-row-hover-background);
32
32
  }
33
33
 
34
34
  /* Keyboard focus (roving tab stop between list rows). */
@@ -41,11 +41,11 @@
41
41
  }
42
42
 
43
43
  .ogx-list-view__row--selected {
44
- background: var(--ogx-selection-bg, rgba(59, 130, 246, 0.08));
44
+ background: var(--ogx-selection-bg, var(--ogx-grid-row-selected-background));
45
45
  }
46
46
 
47
47
  .ogx-list-view__row--selected:hover {
48
- background: var(--ogx-selection-hover-bg, rgba(59, 130, 246, 0.13));
48
+ background: var(--ogx-selection-hover-bg, var(--ogx-grid-row-selected-hover-background));
49
49
  }
50
50
 
51
51
  /* ─── Checkbox column inside list row ────────────────────────────────────── */
@@ -79,7 +79,7 @@
79
79
  min-width: 0;
80
80
  font-family: var(--ogx-font-family);
81
81
  font-size: var(--ogx-font-size-sm);
82
- color: var(--ogx-grid-cell-text);
82
+ color: var(--ogx-grid-row-text);
83
83
  }
84
84
 
85
85
  /* ─── Toolbar in list view mode ──────────────────────────────────────────── */
@@ -245,7 +245,9 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
245
245
  getRowId = defaultGetRowId,
246
246
  } = params;
247
247
 
248
- return useMemo<LayoutResult<R>>(() => {
248
+ // Rows and columns are measured in separate memos: a column resize must not walk every row
249
+ // (1M rows), and a sort or filter must not recompute column widths.
250
+ const rowLayout = useMemo(() => {
249
251
  const unpinnedRows = pagination ? paginatedUnpinnedRows : sortedUnpinnedRows;
250
252
 
251
253
  const heightOf = (row: R, rowIndex: number) => {
@@ -279,6 +281,35 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
279
281
  const pinnedTopHeight = pinnedBlockHeight(pinnedTopRows, pinnedTopRowsLength);
280
282
  const pinnedBottomHeight = pinnedBlockHeight(pinnedBottomRows, pinnedBottomRowsLength);
281
283
 
284
+ return {
285
+ rowHeights,
286
+ cumulativeHeights,
287
+ unpinnedRowsHeight,
288
+ pinnedTopHeight,
289
+ pinnedBottomHeight,
290
+ pinnedTopRowsLength,
291
+ pinnedBottomRowsLength,
292
+ unpinnedRowsLength: rowCount,
293
+ };
294
+ }, [
295
+ rowHeight,
296
+ pagination,
297
+ paginatedUnpinnedRows,
298
+ sortedUnpinnedRows,
299
+ expandedRowIds,
300
+ getDetailPanelHeight,
301
+ detailPanelHeights,
302
+ pinnedTopRowsLength,
303
+ pinnedBottomRowsLength,
304
+ pinnedTopRows,
305
+ pinnedBottomRows,
306
+ paginationMode,
307
+ isLoading,
308
+ pageSize,
309
+ getRowId,
310
+ ]);
311
+
312
+ const columnLayout = useMemo(() => {
282
313
  // Pinned columns render in pinnedColumns.left / .right order; unpinned ones in column order.
283
314
  const { left: rawLeftPinnedCols, center: unpinnedCols, right: rawRightPinnedCols } =
284
315
  getPinnedColumnGroups(visibleOrderedColumns, pinnedColumns);
@@ -326,11 +357,6 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
326
357
  }, [] as number[]);
327
358
 
328
359
  return {
329
- rowHeights,
330
- cumulativeHeights,
331
- unpinnedRowsHeight,
332
- pinnedTopHeight,
333
- pinnedBottomHeight,
334
360
  leftPinnedCols,
335
361
  rightPinnedCols,
336
362
  unpinnedColsWithWidth,
@@ -342,22 +368,8 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
342
368
  unpinnedTotalWidth,
343
369
  systemColumnsWidth,
344
370
  pinnedSystemColumnsWidth,
345
- pinnedTopRowsLength,
346
- pinnedBottomRowsLength,
347
- unpinnedRowsLength: unpinnedRows.length,
348
371
  };
349
372
  }, [
350
- rowHeight,
351
- pagination,
352
- paginatedUnpinnedRows,
353
- sortedUnpinnedRows,
354
- expandedRowIds,
355
- getDetailPanelHeight,
356
- detailPanelHeights,
357
- pinnedTopRowsLength,
358
- pinnedBottomRowsLength,
359
- pinnedTopRows,
360
- pinnedBottomRows,
361
373
  visibleOrderedColumns,
362
374
  pinnedColumns,
363
375
  columnWidths,
@@ -367,9 +379,7 @@ export function useLayout<R extends GridRowModel>(params: UseLayoutParams<R>): L
367
379
  rowReordering,
368
380
  pinCheckboxColumn,
369
381
  pinExpandColumn,
370
- paginationMode,
371
- isLoading,
372
- pageSize,
373
- getRowId,
374
382
  ]);
383
+
384
+ return useMemo<LayoutResult<R>>(() => ({ ...rowLayout, ...columnLayout }), [rowLayout, columnLayout]);
375
385
  }
@@ -1,6 +1,8 @@
1
- *,
2
- *::before,
3
- *::after {
1
+ /* Only the grid's own elements, including its portalled menus and panels (all carry an ogx class).
2
+ :where() keeps the specificity at zero, so a consumer's own box-sizing rule still wins. */
3
+ :where([class*="ogx"], [class*="ogx"] *),
4
+ :where([class*="ogx"], [class*="ogx"] *)::before,
5
+ :where([class*="ogx"], [class*="ogx"] *)::after {
4
6
  box-sizing: border-box;
5
7
  }
6
8
  .ogx--dragging {
@@ -1,6 +1,6 @@
1
1
  import type { GridAggregationModel, GridColDef, GridGroupedExportRow, GridRowId, GridRowModel } from '../../types';
2
2
  import { computeAggregations, formatAggregateForColumn } from '../aggregation';
3
- import { formatValueByType } from '../values';
3
+ import { formatValueByType, getCellValue } from '../values';
4
4
  import { isSystemField } from '../focus';
5
5
 
6
6
  /**
@@ -77,10 +77,12 @@ export function aggregationForExport<R extends GridRowModel>(
77
77
  return computeAggregations(exportedRows, aggregationModel, lookup, 'export');
78
78
  }
79
79
 
80
- /** The cell's raw value: `row[field]`, then the column's `valueGetter`. */
80
+ /**
81
+ * The cell's raw value, read like the grid reads it (`getCellValue`): a `valueGetter` that throws
82
+ * exports as an empty value instead of aborting the export.
83
+ */
81
84
  export function getRawExportValue<R extends GridRowModel>(row: R, col: GridColDef<R>): unknown {
82
- const value: unknown = (row as GridRowModel)[col.field];
83
- return col.valueGetter ? col.valueGetter({ row, field: col.field, value }) : value;
85
+ return getCellValue(row, col.field, col);
84
86
  }
85
87
 
86
88
  /**
@@ -159,6 +161,22 @@ export function neutralizeFormula(text: string, fromNonText = false): string {
159
161
  return `'${text}`;
160
162
  }
161
163
 
164
+ /**
165
+ * Text that reads as a formatted number (digits, signs, separators, brackets, %, currency symbols),
166
+ * which no spreadsheet runs as a formula. One character class: linear on any input.
167
+ */
168
+ const NUMERIC_DISPLAY = /^[\d\s.,'%+\-()\p{Sc}]*$/u;
169
+
170
+ /**
171
+ * Whether a data cell's text may skip formula escaping: the grid formatted a number, date or
172
+ * boolean itself, or a `valueFormatter` turned such a value into plain numeric text ("-$3").
173
+ * Anything else a formatter writes is escaped like text, since it may come from user data.
174
+ */
175
+ export function isTrustedNonText(raw: unknown, text: string, hasFormatter: boolean): boolean {
176
+ if (!isNonTextValue(raw)) return false;
177
+ return !hasFormatter || NUMERIC_DISPLAY.test(text);
178
+ }
179
+
162
180
  /** Whether a raw value is a number, bigint, boolean or Date, whose formatted text is not user-typed. */
163
181
  export function isNonTextValue(value: unknown): boolean {
164
182
  return typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean' || value instanceof Date;
@@ -10,6 +10,7 @@ import {
10
10
  getRawExportValue,
11
11
  hasSelection,
12
12
  isNonTextValue,
13
+ isTrustedNonText,
13
14
  neutralizeFormula,
14
15
  pickExportedAggregates,
15
16
  rowsForExport,
@@ -55,11 +56,14 @@ export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
55
56
  interface ExportCell {
56
57
  text: string;
57
58
  raw: unknown;
59
+ /** The text came from the grid's own formatting of a number, date or boolean (see `isTrustedNonText`). */
60
+ fromNonText: boolean;
58
61
  }
59
62
 
60
63
  function exportCell<R extends GridRowModel>(row: R, col: GridColDef<R>): ExportCell {
61
64
  const raw = getRawExportValue(row, col);
62
- return { text: formatExportValue(row, col, raw), raw };
65
+ const text = formatExportValue(row, col, raw);
66
+ return { text, raw, fromNonText: isTrustedNonText(raw, text, !!col.valueFormatter) };
63
67
  }
64
68
 
65
69
  /** Aggregate text per export column for one subtotal / total record. */
@@ -111,7 +115,7 @@ export function exportToCsv<R extends GridRowModel>(
111
115
  const row = entry.row as R;
112
116
  const cells = exportColumns.map((col, i) => {
113
117
  const cell = exportCell(row, col);
114
- const text = escapeFormulas ? neutralizeFormula(cell.text, isNonTextValue(cell.raw)) : cell.text;
118
+ const text = escapeFormulas ? neutralizeFormula(cell.text, cell.fromNonText) : cell.text;
115
119
  return escapeCSV((i === 0 ? indent : '') + text, delimiter);
116
120
  });
117
121
  csvContent += cells.join(delimiter) + '\n';
@@ -138,7 +142,7 @@ export function exportToCsv<R extends GridRowModel>(
138
142
  rowsToExport.forEach(row => {
139
143
  const values = exportColumns.map(col => {
140
144
  const cell = exportCell(row, col);
141
- return field(cell.text, isNonTextValue(cell.raw));
145
+ return field(cell.text, cell.fromNonText);
142
146
  });
143
147
  csvContent += values.join(delimiter) + '\n';
144
148
  });
@@ -255,7 +259,7 @@ export function exportToExcel<R extends GridRowModel>(
255
259
  html += '<tbody>';
256
260
 
257
261
  const dataCell = (cell: ExportCell, col: GridColDef<R>, prefix = ''): string => {
258
- const content = prefix + cellText(cell.text, isNonTextValue(cell.raw));
262
+ const content = prefix + cellText(cell.text, cell.fromNonText);
259
263
  return isXlsTextCell(col, cell.raw) ? textTd(content) : `<td>${content}</td>`;
260
264
  };
261
265
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  import type { GridRowModel, GridFilterItem, GridFilterModel, GridFilterGroup, GridFilterOperator, GridColDef } from '../../types';
3
3
  import type { GridColumnLookup } from '../columnLookup';
4
- import { getCellValue, getFormattedValue, toLocalDateString, toLocalDayKey, toNumber } from '../values';
4
+ import { getCellValue, formatValueByType, getFormattedValue, toLocalDateString, toLocalDayKey, toNumber } from '../values';
5
5
 
6
6
  /** Compares a cell value with the filter item's value. */
7
7
  export type GridFilterOperatorFn = (value: unknown, filterValue?: unknown) => boolean;
@@ -230,7 +230,13 @@ function getQuickFilterTexts(row: GridRowModel, columns: GridColumnLookup | unde
230
230
  for (const col of columns.quickFilterColumns) {
231
231
  const value = getCellValue(row, col.field, col);
232
232
  const formatted = getFormattedValue(row, col.field, value, col);
233
- if (formatted != null && formatted !== '') texts.push(String(formatted).toLowerCase());
233
+ if (formatted != null && formatted !== '') {
234
+ texts.push(String(formatted).toLowerCase());
235
+ } else if (col.type === 'date' || col.type === 'boolean' || col.type === 'singleSelect') {
236
+ // Without a formatter the grid shows a locale date, Yes / No or the option label: search that too.
237
+ const shown = formatValueByType(value, col);
238
+ if (shown !== '') texts.push(shown.toLowerCase());
239
+ }
234
240
  pushSearchText(texts, value);
235
241
  }
236
242
  return texts;
@@ -131,6 +131,19 @@ export function parseNumberText(text: string): GridParsedValue {
131
131
 
132
132
  const ISO_DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
133
133
  const pad2 = (n: number) => String(n).padStart(2, '0');
134
+ /** Whether an ISO date-time string ends with `Z` or a `±hh:mm` / `±hhmm` offset. A loop, not a regex. */
135
+ function hasZone(text: string): boolean {
136
+ const end = text.trimEnd();
137
+ if (end.endsWith('Z') || end.endsWith('z')) return true;
138
+ const tIndex = end.indexOf('T');
139
+ if (tIndex < 0) return false;
140
+ for (let i = end.length - 1; i > tIndex; i--) {
141
+ const ch = end[i];
142
+ if (ch === '+' || ch === '-') return true;
143
+ if (!(ch >= '0' && ch <= '9') && ch !== ':') return false;
144
+ }
145
+ return false;
146
+ }
134
147
  const localDay = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
135
148
 
136
149
  /**
@@ -147,7 +160,17 @@ export function parseDateText(text: string, current?: unknown): GridParsedValue
147
160
  if (typeof current === 'number') return valid(date.getTime());
148
161
  if (typeof current === 'string') {
149
162
  const day = localDay(date);
150
- return valid(ISO_DATE_PREFIX.test(current) && current.length > 10 ? day + current.slice(10) : day);
163
+ if (!ISO_DATE_PREFIX.test(current) || current.length <= 10) return valid(day);
164
+ // A zoned date-time ("…Z", "…+05:30") is shown as a local day: keep its local time of day on
165
+ // the pasted day and write UTC, or west of UTC the cell would show the day before.
166
+ const held = hasZone(current) ? new Date(current) : null;
167
+ if (held && !Number.isNaN(held.getTime())) {
168
+ return valid(new Date(
169
+ date.getFullYear(), date.getMonth(), date.getDate(),
170
+ held.getHours(), held.getMinutes(), held.getSeconds(), held.getMilliseconds(),
171
+ ).toISOString());
172
+ }
173
+ return valid(day + current.slice(10));
151
174
  }
152
175
  return valid(date);
153
176
  }
@@ -132,6 +132,7 @@ export function toDate(value: unknown): Date | null {
132
132
  // Reject roll-overs such as 2024-02-31.
133
133
  if (date.getMonth() !== month || date.getDate() !== day) return null;
134
134
  } else {
135
+ if (!hasOnlyDateWords(trimmed)) return null;
135
136
  date = new Date(trimmed);
136
137
  }
137
138
  } else {
@@ -140,6 +141,36 @@ export function toDate(value: unknown): Date | null {
140
141
  return Number.isNaN(date.getTime()) ? null : date;
141
142
  }
142
143
 
144
+ const DATE_WORDS = [
145
+ 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october',
146
+ 'november', 'december', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
147
+ ];
148
+ const DATE_TOKENS = new Set(['t', 'z', 'am', 'pm', 'gmt', 'utc', 'sept']);
149
+
150
+ function isDateWord(word: string): boolean {
151
+ if (DATE_TOKENS.has(word)) return true;
152
+ return word.length >= 3 && DATE_WORDS.some(name => name.startsWith(word));
153
+ }
154
+
155
+ /**
156
+ * Whether every run of letters in `text` is a month or weekday name (or abbreviation), or one of
157
+ * T, Z, AM, PM, GMT, UTC. `Date.parse` accepts free text such as "hello 5" (as a 2001 date in V8),
158
+ * which must not become a date. A loop, not a regex.
159
+ */
160
+ function hasOnlyDateWords(text: string): boolean {
161
+ let word = '';
162
+ for (let i = 0; i <= text.length; i++) {
163
+ const ch = i < text.length ? text[i].toLowerCase() : '';
164
+ if (ch >= 'a' && ch <= 'z') {
165
+ word += ch;
166
+ continue;
167
+ }
168
+ if (word !== '' && !isDateWord(word)) return false;
169
+ word = '';
170
+ }
171
+ return true;
172
+ }
173
+
143
174
  /** A sortable integer for the local calendar day of a date (YYYYMMDD), or null when unparsable. */
144
175
  export function toLocalDayKey(value: unknown): number | null {
145
176
  const date = toDate(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencorestack/opengridx",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "main": "./dist/opengridx.umd.js",