Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@
"sources": [
"packages/core/src/components/data-table/**",
"packages/core/src/components/internals/table-core/column-types.ts",
"packages/core/src/components/internals/table-core/cell-edit-error.ts",
"packages/core/src/hooks/use-collection-variables.ts",
"packages/core/src/lib/collection-url-state.ts",
"packages/core/src/contexts/collection-control-context.tsx",
Expand Down Expand Up @@ -475,7 +476,7 @@
],
"hashes": {
"typeSurface": "33eb2bdd6be1c774",
"outline": "3543772d5bd1ec9a",
"outline": "36328fa06e4b4612",
"snapshot": "a97e067c66e46ef6",
"outputMd": "7133811af89213ef",
"examples": "e7100d7517f72495"
Expand Down Expand Up @@ -549,10 +550,10 @@
"claims": [],
"symbols": ["I18nLabels", "defineI18nLabels"],
"hashes": {
"typeSurface": "38a1d271bf54751a",
"typeSurface": "cd254008bb9d09a5",
"outline": "9a2fa5d9f1b2af3f",
"snapshot": null,
"outputMd": "3035dea9ac31b7f5",
"outputMd": "d5e44472b7a0d2ff",
"examples": "e477bcbfa866cfda"
}
},
Expand Down Expand Up @@ -1823,7 +1824,7 @@
"packages/core/skills/app-shell-patterns/references/api/aggregations.md": "daf6c93e7285ee88",
"packages/core/skills/app-shell-patterns/references/api/create-ai-gateway-client.md": "ed0db6372d25e2f9",
"packages/core/skills/app-shell-patterns/references/api/create-typed-paths.md": "4c3a858d3eb02a2a",
"packages/core/skills/app-shell-patterns/references/api/define-i18n-labels.md": "3035dea9ac31b7f5",
"packages/core/skills/app-shell-patterns/references/api/define-i18n-labels.md": "d5e44472b7a0d2ff",
"packages/core/skills/app-shell-patterns/references/api/define-module.md": "75361d7687b43878",
"packages/core/skills/app-shell-patterns/references/api/define-resource.md": "27e4d84b41cf40e0",
"packages/core/skills/app-shell-patterns/references/api/guards/hidden.md": "dda25877f7e40b82",
Expand Down
1 change: 1 addition & 0 deletions docs-src/components/data-table.docs.outline.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ description: Compound data table component with sortable columns, filter chips,
sources:
- packages/core/src/components/data-table/**
- packages/core/src/components/internals/table-core/column-types.ts
- packages/core/src/components/internals/table-core/cell-edit-error.ts
- packages/core/src/hooks/use-collection-variables.ts
- packages/core/src/lib/collection-url-state.ts
- packages/core/src/contexts/collection-control-context.tsx
Expand Down
2 changes: 1 addition & 1 deletion docs/api/define-i18n-labels.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 0 additions & 7 deletions packages/core/src/components/data-table/cell-edit-error.ts

This file was deleted.

67 changes: 8 additions & 59 deletions packages/core/src/components/data-table/cell-renderers.tsx
Original file line number Diff line number Diff line change
@@ -1,76 +1,25 @@
import type { ReactNode } from "react";
import { createValueRenderer } from "@/components/internals/table-core";
import { optionLabel, resolveBadgeOptions } from "./cell-values";
import { resolveDataTableColumn } from "./column-adapter";
import { getCellValue } from "./cell-values";
import type { Column } from "./types";

/**
* Read the raw cell value for a column. Used by the built-in `type`
* renderers and by `truncate`'s tooltip so they share one precedence
* rule: explicit `accessor` wins, otherwise fall back to `row[col.id]`
* when `id` is set.
*
* @internal
*/
export function getCellValue<TRow extends Record<string, unknown>>(
row: TRow,
col: Column<TRow>,
): unknown {
if (col.accessor) return col.accessor(row);
if (col.id) return row[col.id];
return undefined;
}
export { getCellValue } from "./cell-values";

/**
* Create a column renderer that reuses date formatters across rows.
* Callers should prefer `col.render` when it is defined.
*
* @internal
*/
/** Build once per column/timezone so the display adapter retains formatter caches. */
export function createTypedCellRenderer<TRow extends Record<string, unknown>>(
col: Column<TRow>,
timeZone: string,
): (row: TRow) => ReactNode {
if (col.type === "date") {
const renderDate = createValueRenderer(col, timeZone);
return (row) => renderDate(getCellValue(row, col), row);
}
return (row) => renderTypedValue(row, col, getCellValue(row, col));
const adapter = resolveDataTableColumn(col, timeZone);
return (row) => adapter.renderValue(getCellValue(row, col), row);
}

/**
* Render the consumer's current value with the column's built-in renderer.
* Editable cells pass `linkAsText` because an editable link is an input,
* not a navigation target.
*
* @internal
*/
/** Compatibility entrypoint; label and editing-display policies belong to the adapter. */
export function renderTypedValue<TRow extends Record<string, unknown>>(
row: TRow,
col: Column<TRow>,
value: unknown,
options?: { linkAsText?: boolean; timeZone?: string },
): ReactNode {
const timeZone =
options?.timeZone ??
(col.type === "date" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC");
switch (col.type) {
case "badge": {
const choices = col.edit
? resolveBadgeOptions(col.edit.options, col.typeOptions, col.filter)
: undefined;
return createValueRenderer(col, timeZone, {
resolveBadgeLabel: choices ? (item) => String(optionLabel(choices, item)) : undefined,
})(value, row);
}
case "link": {
// An editable link is an input, not navigation; its choice label is resolved here.
const label = optionLabel(col.edit?.options, value);
const descriptor = options?.linkAsText ? ({ type: "text" } as const) : col;
return createValueRenderer(descriptor, timeZone)(label, row);
}
case "text":
return createValueRenderer(col, timeZone)(optionLabel(col.edit?.options, value), row);
default:
return createValueRenderer(col, timeZone)(value, row);
}
return resolveDataTableColumn(col, options?.timeZone).renderValue(value, row, options);
}
89 changes: 0 additions & 89 deletions packages/core/src/components/data-table/cell-values.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,97 +7,8 @@ import {
toCalendarDate,
toIsoDateTime,
toTimeText,
normalizeNumberText,
parseNumberText,
sameNumber,
toNumberEditText,
toNumberValue,
toTextValue,
} from "./cell-values";

describe("typed text that isn't a number", () => {
it("is reported as not a number, not silently accepted", () => {
for (const text of ["e", "1e5", "+5", "0x1f", "12a", "1,5"]) {
expect(parseNumberText(normalizeNumberText(text))).toBeUndefined();
}
});
});

describe("normalizeNumberText", () => {
it("drops strict thousands grouping", () => {
expect(normalizeNumberText("1,234.5")).toBe("1234.5");
expect(normalizeNumberText("-1,234,567")).toBe("-1234567");
});

it("leaves a decimal comma alone so it fails instead of becoming 15", () => {
expect(normalizeNumberText("1,5")).toBe("1,5");
expect(parseNumberText(normalizeNumberText("1,5"))).toBeUndefined();
});

it("converts full-width digits and signs", () => {
expect(normalizeNumberText("12.5")).toBe("12.5");
expect(normalizeNumberText("-3")).toBe("-3");
expect(normalizeNumberText("−3")).toBe("-3");
});

it("strips whitespace and currency symbols", () => {
expect(normalizeNumberText(" $1,234.50 ")).toBe("1234.50");
expect(normalizeNumberText("¥ 1 200")).toBe("1200");
});
});

describe("parseNumberText", () => {
it("treats an empty draft as null", () => {
expect(parseNumberText("")).toBeNull();
expect(parseNumberText(" ")).toBeNull();
});

it("parses partial but complete-enough numbers without business constraints", () => {
expect(parseNumberText("-3.5678")).toBe(-3.5678);
expect(parseNumberText("500.0001")).toBe(500.0001);
expect(parseNumberText("5.")).toBe(5);
expect(parseNumberText(".5")).toBe(0.5);
expect(parseNumberText("-0")).toBe(0);
expect(Object.is(parseNumberText("-0"), -0)).toBe(false);
});

it("rejects half-typed and non-numeric drafts", () => {
for (const text of ["-", ".", "-.", "abc", "1,5", String(Number.MAX_SAFE_INTEGER * 2)]) {
expect(parseNumberText(text)).toBeUndefined();
}
});
});

describe("value helpers", () => {
it("compares parsed numbers without silently rounding distinct values", () => {
expect(sameNumber(10, 10)).toBe(true);
expect(sameNumber(0.1 + 0.2, 0.3)).toBe(false);
expect(sameNumber(1234567890123456, 1234567890123457)).toBe(false);
expect(sameNumber(null, null)).toBe(true);
expect(sameNumber(0, null)).toBe(false);
expect(sameNumber(10, 10.01)).toBe(false);
});

it("reads raw values the way the renderers do", () => {
expect(toNumberValue("10.00")).toBe(10);
expect(toNumberValue("")).toBeNull();
expect(toNumberValue("abc")).toBeNull();
expect(toTextValue("")).toBeNull();
expect(toTextValue(42)).toBe("42");
});

it("formats numbers without grouping, exponents, or precision loss", () => {
expect(toNumberEditText(1234567.5)).toBe("1234567.5");
expect(toNumberEditText(0.1 + 0.2)).toBe("0.30000000000000004");
expect(toNumberEditText(1234567890123456)).toBe("1234567890123456");
expect(toNumberEditText(1e21)).toBe("1000000000000000000000");
expect(toNumberEditText(null)).toBe("");
for (const value of [Number.MAX_SAFE_INTEGER, Number.MIN_VALUE, 1e-30, 1.2345678901234567]) {
expect(parseNumberText(toNumberEditText(value))).toBe(value);
}
});
});

describe("choices", () => {
const choices = [
{ value: "acme", label: "Acme Corp" },
Expand Down
72 changes: 13 additions & 59 deletions packages/core/src/components/data-table/cell-values.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Pure helpers behind DataTable's inline cell editing: how typed or pasted text
* becomes a value and whether a draft is a real change or a cosmetic one
* (`10` vs `10.00`). Business validation belongs to the consumer.
* Pure helpers for DataTable's choice labels and date/time conversion.
* Business validation belongs to the consumer.
*
* @internal
*/
Expand All @@ -15,62 +14,17 @@ import {
} from "@internationalized/date";
import { localDateTimeParts, normalizeTemporalFilterValue } from "@/lib/temporal-filter-values";
import type { SelectOption } from "@/types/collection";
import type { BadgeCellOptions, DataTableFilterConfig } from "./types";

// Thousands grouping with commas, e.g. "1,234,567.89".
const GROUPED_NUMBER = /^-?\d{1,3}(?:,\d{3})+(?:\.\d*)?$/;

/**
* Cleans number text before it's checked: full-width digits and signs become
* ASCII, whitespace and currency symbols are dropped, and commas are removed
* only when they form strict thousands groups — so "1,234.5" becomes "1234.5",
* while "1,5" is left alone to fail as "not a number" instead of silently
* turning into 15.
*/
export function normalizeNumberText(text: string): string {
const compact = text
.normalize("NFKC")
.replace(/[\s\p{Sc}]/gu, "")
.replace(/^−/, "-");
return GROUPED_NUMBER.test(compact) ? compact.replace(/,/g, "") : compact;
}

/** `""` → `null`, `"5."` → `5`; `"-"`, `"."` and anything non-numeric → `undefined`. */
export function parseNumberText(text: string): number | null | undefined {
const trimmed = text.trim();
if (trimmed === "") return null;
if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(trimmed)) return undefined;
const value = Number(trimmed);
if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) return undefined;
// `+ 0` folds -0 into 0 so "-0" doesn't read as a change from 0.
return value + 0;
}

/** Reads a raw cell value as a number the way the `number` renderer does. */
export function toNumberValue(raw: unknown): number | null {
if (raw == null || raw === "") return null;
const value = typeof raw === "number" ? raw : Number(raw);
return Number.isFinite(value) ? value : null;
}

/** Reads a raw cell value as text; `null`, `undefined` and `""` are all empty. */
export function toTextValue(raw: unknown): string | null {
if (raw == null || raw === "") return null;
return String(raw);
}

/** Compare parsed values exactly; rounding belongs to the consumer. */
export function sameNumber(a: number | null, b: number | null): boolean {
return a === b;
}

/** Plain edit text preserves the value without grouping or exponent notation. */
export function toNumberEditText(value: number | null): string {
if (value === null) return "";
return value.toLocaleString("en-US", {
useGrouping: false,
maximumSignificantDigits: 21,
});
import type { BadgeCellOptions } from "@/components/internals/table-core/column-types";
import type { Column, DataTableFilterConfig } from "./types";

/** Raw lookup shared by rendering, editing and context menus; never reads render output. */
export function getCellValue<TRow extends Record<string, unknown>>(
row: TRow,
col: Column<TRow>,
): unknown {
if (col.accessor) return col.accessor(row);
if (col.id) return row[col.id];
return undefined;
}

// ── Choices (dropdown and badge editors) ─────────────────────────────────────
Expand Down
Loading
Loading