Skip to content
Merged
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: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

## [Unreleased]

### Added

- `bucketLogRange(options?)`/`formatLogRange(options?, unit?, missingLabel?)` (core, re-exported from every adapter) — a ready-made `groupValue`/`groupFormat` pair for bucketing a `type: 'number'` column on a logarithmic scale, for a right-skewed column spanning several orders of magnitude (review counts, hours played, file sizes) where any single linear `bucketNumericRange` step is either too coarse for the long tail or too fine for the low end. `LogRangeOptions` (`{ base?, divisions?, min? }`) generalizes to a plain order-of-magnitude scale (`base: 10`, default `divisions: [1]`), octaves/binary doublings (`base: 2`), a half-decade "1-3-10" grid (`divisions: [1, 3]`), or any other per-`base`-cycle split (#18)
- `numericRangeGroup(step, unit?, missingLabel?)`/`datePartGroup(part, parseDate?, missingLabel?)`/`logRangeGroup(options?, unit?, missingLabel?)` (core, re-exported from every adapter) — each bundles a bucketer with its matching formatter into one `{ groupValue, groupFormat }` pair from a single set of arguments, spreadable directly into a column def (`{ key: 'hoursPlayed', ...logRangeGroup({ divisions: [1, 3] }) }`), removing the config-divergence risk of passing the same `step`/`unit`/`part`/`options` to both halves separately (#18)

### Fixed

- `bucketNumericRange`/`bucketDatePart` now return `null` for a missing (`null`/`undefined`) value instead of silently coercing it — `bucketNumericRange` previously read `Number(null) === 0`, merging "no value" into the same group as a real, confirmed `0`; `bucketDatePart` previously read `String(null) === "null"`, surfacing the literal text `"null"` as a group header. `bucketNumericRange` also now returns `null` (rather than `NaN`) for a non-numeric value, since `NaN` previously flowed through to a group key that stringified to the literal visible text `"NaN"`. `formatNumericRange`/`formatDatePart` each gained a 3rd `missingLabel = '(none)'` parameter rendered for that group (#18)

## [0.10.0] - 2026-08-21

### Added
Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,15 @@ When a column is added to `groupBy`, `useTableState` removes it from `activeColu

`ColumnDefBase.keepVisibleWhenGrouped?: boolean` (default `false`) opts a column out of that auto-hide (GitHub issue #16) — for the two cases where the group header no longer shows the same thing the row would, so hiding is a net information loss rather than a redundancy: a `groupValue`-bucketed column (header shows only the bucket, e.g. "3–10h" — the row's exact value is otherwise visible nowhere) and a multi-value/array column (grouping fans a row out into one group per value, so hiding the column removes the only way to see a row's _other_ values from within one particular group's expansion). Deliberately explicit rather than auto-detected off `groupValue`/array-shaped values — `value` is an arbitrary function, so whether a column's cells can be arrays isn't knowable from the column def alone, only from actual data. `activeColumns`' filter (identical across React/Vue/Solid — vanilla gets it for free) became `visibleCols.has(c.key) && (!groupBy.includes(c.key) || c.keepVisibleWhenGrouped === true)`; rendering when kept visible is unchanged (the same `format`/render/slot pipeline as ungrouped, showing the row's real value — including the full array for a multi-value column — with no filtering-out of the value that matched the current group).

`ColumnDefBase.groupValue?: (value, row) => unknown` lets a column bucket into a coarser group key than its exact value (e.g. a percentage rounded to a 10-point range, a timestamp truncated to its month) — spliced into `groupData` right where the exact-value key would otherwise be read, affecting grouping only. `ColumnDefBase.groupFormat?: (keyPart) => string` renders that bucket key for the group header, bypassing the column's normal `format`/render/slot pipeline. `bucketNumericRange`/`formatNumericRange`/`bucketDatePart`/`formatDatePart` (core, re-exported from every adapter) are ready-made pairs for the two common cases.
`ColumnDefBase.groupValue?: (value, row) => unknown` lets a column bucket into a coarser group key than its exact value (e.g. a percentage rounded to a 10-point range, a timestamp truncated to its month) — spliced into `groupData` right where the exact-value key would otherwise be read, affecting grouping only. `ColumnDefBase.groupFormat?: (keyPart) => string` renders that bucket key for the group header, bypassing the column's normal `format`/render/slot pipeline. `bucketNumericRange`/`formatNumericRange`/`bucketDatePart`/`formatDatePart`/`bucketLogRange`/`formatLogRange` (core, re-exported from every adapter) are ready-made pairs for the common cases — see "Ready-made `groupValue` bucketers" below.

### Ready-made `groupValue` bucketers (GitHub issue #18)

`bucketNumericRange(step)`/`bucketDatePart(part, parseDate?)` return `null` for a missing (`null`/`undefined`) or (for the numeric one) non-numeric value, rather than coercing it — `Number(null) === 0` would otherwise silently merge "no value" into the same bucket as a real, confirmed `0`, and `Number(undefined)`/`Number('abc')` being `NaN`, or `String(null) === "null"`, would otherwise flow through `groupData`'s `multiValues` to a group key that stringifies to the literal visible text `"NaN"`/`"null"`. `null` stringifies to `''` the same way an _unbucketed_ missing scalar already does (`multiValues`' `String(value ?? '')`), so a bucketed column's missing-value group lands on the exact same key an unbucketed column's missing rows already use — no new sentinel introduced. `formatNumericRange`/`formatDatePart` each take a 3rd `missingLabel = '(none)'` param and render it for that `''` key; a genuinely invalid _but present_ value (e.g. `bucketDatePart`'s raw string when it doesn't parse as a date) still returns unchanged, since — unlike a missing value — it's real text worth keeping.

`bucketLogRange(options?)`/`formatLogRange(options?, unit?, missingLabel?)` bucket a `type: 'number'` column on a logarithmic scale instead of a linear step — for a right-skewed column spanning several orders of magnitude (review counts, hours played, file sizes), where any single linear `bucketNumericRange` step is either too coarse for the long tail or too fine for the low end. `LogRangeOptions` is `{ base?, divisions?, min? }`: `base` (default `10`) is the multiplier per exponent step (`2` for octaves/binary doublings); `divisions` (default `[1]`, plain order-of-magnitude) lists the bucket starts within one `base` cycle as multipliers of `base ** exponent` — `[1, 3]` splits each decade into a "1–3"/"3–10" half-decade grid, `[1, 2, 5]` gives the classic "1-2-5" grid; `min` (default `1`) collapses everything below it (and always `<= 0`, since `log` is undefined there) into one low bucket instead of extending the grid toward zero — pass `min: 0` to opt out of the collapse for positive values (zero/negative still always collapse, regardless of `min`). The below-`min` bucket uses a dedicated `-Infinity` sentinel, kept distinct from the `null` "missing" key so the two don't merge; `formatLogRange` renders it as `"<<min><unit>"`. Bucket boundaries are always an exact `base ** exponent * divisions[i]`, so `formatLogRange`'s k/M magnitude-suffix formatting (`>= 1e3` → `k`, `>= 1e6` → `M`) never needs decimal rounding. No named preset division arrays (e.g. IEC 60063's E-series, ISO 3's Renard series) ship built in — `divisions` accepts any array, and a consumer needing a named standard passes its values directly.

`numericRangeGroup(step, unit?, missingLabel?)`/`datePartGroup(part, parseDate?, missingLabel?)`/`logRangeGroup(options?, unit?, missingLabel?)` each return `{ groupValue, groupFormat }` — the exact property names `ColumnDefBase` uses — from one set of arguments, since `groupValue`/`groupFormat` otherwise need the same `step`/`unit`, `part`, or `options`/`unit` passed to both independently; a mismatch between the two (e.g. changing `step` on one side and forgetting the other) previously failed silently, producing a group header that disagreed with its own bucket's real boundaries. Spread directly into a column def: `{ key: 'hoursPlayed', ...logRangeGroup({ divisions: [1, 3] }, 'h') }`. The standalone `bucket*`/`format*` functions stay exported too, for a consumer that wants just one half or needs to pass mismatched-on-purpose arguments (e.g. a `unit` that only applies to the header, not the bucketing itself — not a real case today, but the two were never coupled at the type level).

`defaultGroupsCollapsed` (default `true`) controls initial collapse state via `isGroupCollapsed(collapsedGroups, key, defaultCollapsed)` — `collapsedGroups` is reinterpreted as _manual toggles away from the default_ rather than absolute state, so a never-seen group key picks up the default for free with no seeding needed.

Expand Down
37 changes: 20 additions & 17 deletions demo/react/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ import {
useUrlView,
resetView,
usePersistence,
bucketNumericRange,
formatNumericRange,
bucketDatePart,
formatDatePart,
numericRangeGroup,
datePartGroup,
compareMissingLast,
LABELS_EN,
LABELS_FR,
Expand All @@ -28,7 +26,7 @@ interface Employee {
name: string
department: string
role: string
salary: number
salary: number | null // null: payroll hasn't been finalized yet — bucketNumericRange/numericRangeGroup group these under their own "(none)" bucket instead of miscounting them as $0 (issue #18)
joined: string
status: string
score: number | null // null: no performance review yet — compareMissingLast() keeps these last regardless of sort direction
Expand Down Expand Up @@ -85,7 +83,7 @@ const SAMPLE_DATA: Employee[] = [
name: 'Eva Müller',
department: 'Engineering',
role: 'Junior Dev',
salary: 62000,
salary: null, // just joined, payroll not finalized yet
joined: '2023-04-05',
status: 'Active',
score: null, // just joined, no review yet
Expand Down Expand Up @@ -311,18 +309,22 @@ const COLUMNS: ColumnDef<Employee>[] = [
type: 'number',
width: 110,
format: (v) =>
Number(v).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}),
v == null
? '—'
: Number(v).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}),
aggregate: 'sum',
// groupValue/groupFormat: a continuous column (near-unique per row) grouped by its exact
// value would create one group per row — bucketing into $20k ranges makes it groupable
// meaningfully. cell rendering/sort/filter above are untouched, still reading the real salary.
// numericRangeGroup bundles both from one call instead of passing `20000`/`' USD'` twice
// (issue #18); a null salary (Eva, just joined) lands in its own "(none)" group instead of
// being miscounted as $0.
groupable: true,
groupValue: bucketNumericRange(20000),
groupFormat: formatNumericRange(20000, ' USD'),
...numericRangeGroup(20000, ' USD'),
},
// type: 'date' gets a range filter (2 inputs + a slider) above a Year › Month › Day filter
// tree, instead of a plain checklist — the range narrows the tree itself. Grouped by year (not
Expand All @@ -337,8 +339,7 @@ const COLUMNS: ColumnDef<Employee>[] = [
width: 100,
defaultSortDir: 'desc',
groupable: true,
groupValue: bucketDatePart('year'),
groupFormat: formatDatePart('year'),
...datePartGroup('year'),
},
// computed column: value is a function, so there's no matching 'tenure' property on Employee —
// sort/filter/group/aggregate all work off the function's return value just like a real column
Expand Down Expand Up @@ -525,8 +526,10 @@ function DocLink({ anchor, children }: { anchor: string; children: ReactNode })
)
}

function fmtSalary(n: number) {
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
function fmtSalary(n: number | null) {
return n == null
? '—'
: n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
}

// Headless section: useTableState owns the sort/filter logic; you own the render.
Expand Down
6 changes: 5 additions & 1 deletion demo/react/src/hugeData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ColumnDef } from '@vates/data-table-react'
import { logRangeGroup, type ColumnDef } from '@vates/data-table-react'

export interface HugeRow {
id: number
Expand Down Expand Up @@ -160,6 +160,10 @@ export const HUGE_COLUMNS: ColumnDef<HugeRow>[] = [
type: 'number',
aggregate: 'sum',
format: (v) => `$${Number(v).toFixed(2)}`,
// logRangeGroup: bucketing 100k rows on a log scale performs the same as bucketNumericRange
// (both are O(1) per row) — a good spot to exercise it at scale (issue #18).
groupable: true,
...logRangeGroup({ divisions: [1, 3] }, '$'),
},
{ key: 'orderDate', label: 'Order Date', type: 'date' },
]
37 changes: 20 additions & 17 deletions demo/solid/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import { createSignal, createEffect, onMount, onCleanup, on, Show, For, type JSX
import {
DataTableView,
createTableState,
bucketNumericRange,
formatNumericRange,
bucketDatePart,
formatDatePart,
numericRangeGroup,
datePartGroup,
compareMissingLast,
usePersistedView,
useUrlView,
Expand All @@ -28,7 +26,7 @@ interface Employee {
name: string
department: string
role: string
salary: number
salary: number | null // null: payroll hasn't been finalized yet — bucketNumericRange/numericRangeGroup group these under their own "(none)" bucket instead of miscounting them as $0 (issue #18)
joined: string
status: string
score: number | null // null: no performance review yet — compareMissingLast() keeps these last regardless of sort direction
Expand Down Expand Up @@ -85,7 +83,7 @@ const SAMPLE_DATA: Employee[] = [
name: 'Eva Müller',
department: 'Engineering',
role: 'Junior Dev',
salary: 62000,
salary: null, // just joined, payroll not finalized yet
joined: '2023-04-05',
status: 'Active',
score: null, // just joined, no review yet
Expand Down Expand Up @@ -322,18 +320,22 @@ const COLUMNS: ColumnDef<Employee>[] = [
type: 'number',
width: 110,
format: (v) =>
Number(v).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}),
v == null
? '—'
: Number(v).toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}),
aggregate: 'sum',
// groupValue/groupFormat: a continuous column (near-unique per row) grouped by its exact
// value would create one group per row — bucketing into $20k ranges makes it groupable
// meaningfully. cell rendering/sort/filter above are untouched, still reading the real salary.
// numericRangeGroup bundles both from one call instead of passing `20000`/`' USD'` twice
// (issue #18); a null salary (Eva, just joined) lands in its own "(none)" group instead of
// being miscounted as $0.
groupable: true,
groupValue: bucketNumericRange(20000),
groupFormat: formatNumericRange(20000, ' USD'),
...numericRangeGroup(20000, ' USD'),
},
// type: 'date' gets a range filter (2 inputs + a slider) above a Year › Month › Day filter
// tree, instead of a plain checklist — the range narrows the tree itself. Grouped by year (not
Expand All @@ -348,8 +350,7 @@ const COLUMNS: ColumnDef<Employee>[] = [
width: 100,
defaultSortDir: 'desc',
groupable: true,
groupValue: bucketDatePart('year'),
groupFormat: formatDatePart('year'),
...datePartGroup('year'),
},
// computed column: value is a function, so there's no matching 'tenure' property on Employee —
// sort/filter/group/aggregate all work off the function's return value just like a real column
Expand Down Expand Up @@ -534,8 +535,10 @@ function DocLink(props: { anchor?: string; children: JSX.Element }) {
)
}

function fmtSalary(n: number) {
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
function fmtSalary(n: number | null) {
return n == null
? '—'
: n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })
}

// Headless section: createTableState owns the sort/filter logic; you own the render.
Expand Down
6 changes: 5 additions & 1 deletion demo/solid/src/hugeData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ColumnDef } from '@vates/data-table-solid'
import { logRangeGroup, type ColumnDef } from '@vates/data-table-solid'

export interface HugeRow {
id: number
Expand Down Expand Up @@ -160,6 +160,10 @@ export const HUGE_COLUMNS: ColumnDef<HugeRow>[] = [
type: 'number',
aggregate: 'sum',
format: (v) => `$${Number(v).toFixed(2)}`,
// logRangeGroup: bucketing 100k rows on a log scale performs the same as bucketNumericRange
// (both are O(1) per row) — a good spot to exercise it at scale (issue #18).
groupable: true,
...logRangeGroup({ divisions: [1, 3] }, '$'),
},
{ key: 'orderDate', label: 'Order Date', type: 'date' },
]
6 changes: 5 additions & 1 deletion demo/vanilla/src/hugeData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ColumnDef } from '@vates/data-table-vanilla'
import { logRangeGroup, type ColumnDef } from '@vates/data-table-vanilla'

export interface HugeRow {
id: number
Expand Down Expand Up @@ -160,6 +160,10 @@ export const HUGE_COLUMNS: ColumnDef<HugeRow>[] = [
type: 'number',
aggregate: 'sum',
format: (v) => `$${Number(v).toFixed(2)}`,
// logRangeGroup: bucketing 100k rows on a log scale performs the same as bucketNumericRange
// (both are O(1) per row) — a good spot to exercise it at scale (issue #18).
groupable: true,
...logRangeGroup({ divisions: [1, 3] }, '$'),
},
{ key: 'orderDate', label: 'Order Date', type: 'date' },
]
Loading