laif-ds 1.0.0 → 1.0.2-app-data-grid.76
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.
- package/dist/CHANGELOG.md +282 -0
- package/dist/_virtual/index7.js +5 -2
- package/dist/_virtual/index8.js +2 -5
- package/dist/agent-docs/adoption-report.json +164 -83
- package/dist/agent-docs/components/AppBottomNav.md +180 -49
- package/dist/agent-docs/components/AppCard.md +25 -21
- package/dist/agent-docs/components/AppDataGrid.md +2602 -0
- package/dist/agent-docs/components/AppEditor.md +18 -13
- package/dist/agent-docs/components/AppSelect.md +50 -0
- package/dist/agent-docs/components/AppSidebar.md +504 -11
- package/dist/agent-docs/components/DataTable.md +34 -3
- package/dist/agent-docs/components/DatePicker.md +7 -7
- package/dist/agent-docs/components/FileUploader.md +12 -12
- package/dist/agent-docs/components/GridFilterPanel.md +112 -0
- package/dist/agent-docs/components/Sidebar.md +72 -1
- package/dist/agent-docs/components/SidebarMenuBadge.md +87 -0
- package/dist/agent-docs/components-list.md +47 -2
- package/dist/agent-docs/manifest.json +310 -36
- package/dist/components/design-tokens.js +138 -66
- package/dist/components/nav-tokens.js +41 -0
- package/dist/components/ui/app-bottom-nav.js +125 -130
- package/dist/components/ui/app-data-grid/app-data-grid.js +874 -0
- package/dist/components/ui/app-data-grid/engine/use-grid-column-resize.js +110 -0
- package/dist/components/ui/app-data-grid/engine/use-grid-engine.js +598 -0
- package/dist/components/ui/app-data-grid/engine/use-grid-infinite-loader.js +50 -0
- package/dist/components/ui/app-data-grid/engine/use-grid-virtualizer.js +90 -0
- package/dist/components/ui/app-data-grid/grid-aggregate.js +5 -0
- package/dist/components/ui/app-data-grid/grid-column-registry.js +28 -0
- package/dist/components/ui/app-data-grid/grid-columns.js +204 -0
- package/dist/components/ui/app-data-grid/grid-filter-compile.js +33 -0
- package/dist/components/ui/app-data-grid/grid-filter-operators.js +139 -0
- package/dist/components/ui/app-data-grid/grid-grouping.js +104 -0
- package/dist/components/ui/app-data-grid/grid-header-matrix.js +112 -0
- package/dist/components/ui/app-data-grid/grid-i18n.js +207 -0
- package/dist/components/ui/app-data-grid/grid-mount-count.js +8 -0
- package/dist/components/ui/app-data-grid/grid-reducer.js +200 -0
- package/dist/components/ui/app-data-grid/grid-result-scope.js +35 -0
- package/dist/components/ui/app-data-grid/grid-tokens.js +169 -0
- package/dist/components/ui/app-data-grid/grid-view-preset.js +97 -0
- package/dist/components/ui/app-data-grid/parts/grid-actions-row.js +86 -0
- package/dist/components/ui/app-data-grid/parts/grid-cell-text.js +67 -0
- package/dist/components/ui/app-data-grid/parts/grid-column-registry-panel.js +320 -0
- package/dist/components/ui/app-data-grid/parts/grid-density-control.js +88 -0
- package/dist/components/ui/app-data-grid/parts/grid-filter-chips.js +224 -0
- package/dist/components/ui/app-data-grid/parts/grid-filter-editor.js +197 -0
- package/dist/components/ui/app-data-grid/parts/grid-filter-panel.js +386 -0
- package/dist/components/ui/app-data-grid/parts/grid-filter-row.js +273 -0
- package/dist/components/ui/app-data-grid/parts/grid-fullscreen-toggle.js +29 -0
- package/dist/components/ui/app-data-grid/parts/grid-group-row.js +135 -0
- package/dist/components/ui/app-data-grid/parts/grid-header.js +338 -0
- package/dist/components/ui/app-data-grid/parts/grid-pagination-control.js +218 -0
- package/dist/components/ui/app-data-grid/parts/grid-row.js +181 -0
- package/dist/components/ui/app-data-grid/parts/grid-scroll-area.js +186 -0
- package/dist/components/ui/app-data-grid/parts/grid-selection-bar.js +195 -0
- package/dist/components/ui/app-data-grid/parts/grid-status-surface.js +66 -0
- package/dist/components/ui/app-data-grid/parts/grid-table.js +482 -0
- package/dist/components/ui/app-data-grid/parts/grid-toolbar-slots.js +192 -0
- package/dist/components/ui/app-data-grid/use-grid-filters.js +173 -0
- package/dist/components/ui/app-select.js +332 -222
- package/dist/components/ui/app-sidebar-mobile.js +119 -104
- package/dist/components/ui/app-sidebar.js +516 -313
- package/dist/components/ui/button.js +2 -1
- package/dist/components/ui/gantt/components/Chart/Chart.js +1 -1
- package/dist/components/ui/nav-badge.js +66 -0
- package/dist/components/ui/sidebar.js +272 -207
- package/dist/components/ui/tables/data-cross-table/data-cross-table.js +6 -6
- package/dist/components/ui/tables/data-table/data-table-constants.js +36 -16
- package/dist/components/ui/tables/data-table/data-table-i18n.js +135 -1
- package/dist/css-for-template.css +27 -0
- package/dist/index.d.ts +5872 -144
- package/dist/index.js +473 -406
- package/dist/node_modules/eventemitter3/index2.js +1 -1
- package/dist/styles.css +2 -1
- package/dist/styles.v3.css +1 -1
- package/package.json +25 -7
- /package/dist/agent-docs/components/{truncated-cell.md → TruncatedCell.md} +0 -0
|
@@ -0,0 +1,2602 @@
|
|
|
1
|
+
# AppDataGrid
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
A new data grid, built to be **independent of `DataTable`** — it imports nothing from it, does not break it, and can be mounted on the same page alongside it.
|
|
6
|
+
|
|
7
|
+
> **Status: both plans are closed — the first one's phases (P1-P10) and the ten phases of the COMPLETION plan (Fase 1-10).** `DataTable` is now deprecated in favour of
|
|
8
|
+
> this component (see `components-list.md`), but the two coexist: nothing is removed, every existing
|
|
9
|
+
> call-site keeps compiling, and migration is call-site by call-site.
|
|
10
|
+
> What works today: real rows, real sorting (mouse + keyboard, with `aria-sort`), a real client-side search filter (on the columns that declare `searchable: true` — **opt-in since 2026-09-10**, as in the frozen table), the frozen public contracts, a measured emitter contract, paginated mode, opt-in **virtualization** (P3), **column widths, resizing and left-pinning with horizontal overflow** (P4), and **multi-level headers with the derived `id`/`headers` accessibility chain** (P5).
|
|
11
|
+
> What P6-P10 added, by phase: the designed **density and full-screen** experience, with the provisional `showDensityControl` switch gone (P6); the **lifted filter panel and chips**, whose state lives above the grid in `useGridFilters` and works with no grid in the tree, plus the **filter atom** — the control is derived from `filterType`, so a list is filtered from a list and a date from the picker — and the **288px overlay inventory** the panel became (P7); **infinite scroll and skeleton rows**, which coexist with the data (P8); client-side **row grouping** on one or more columns, with `aggregatedCell` filling the group rows (P9); and **saved views**, applied in one tick through `preset`/`onPresetApplied` (P10). **Riga Zero (C9)** then added the second home for the filters: `filterMode="row"` renders a FOURTH `<tr>` of `<thead>` with one control per column, reusing the Fase 7 atom and the one badge constructor, so the row and the inventory panel produce the same state; `"panel"` stays the default.
|
|
12
|
+
> What the completion plan has added since, by phase: **left AND right pinning with the measured sticky band** and the single `align` derivation (Fase 2); the **multi-level header matrix** hardened against the group that covers zero leaves (Fase 3); **`gridResultScope`, the single source of every scope figure** — paginator, chips and filter trigger read the same object, and on the client branch the paginator is a read-out (Fase 4); and, from **Fase 5**, the three **outcomes without data rendered INSIDE the table** (a `<td>` covering every layout column, ghost column included), an **error distinguishable from an empty result** with its own surface, its own i18n strings and a «Retry» control (`error` + `onRetry`), the **`<tfoot>` of column totals** (`aggregate` per column, sticky at the useful bottom, aligned by the same single derivation), and the rule that **above a body with no data the frame is silent**: no totals, no paginator, no scope figure; the **page-scope selection bar** — count in page, a monetary aggregate as a render-prop, bulk actions that carry the number in their own name («Delete 10 rows»), a destructive variant and a way out — which writes its clause into the one permanent live region instead of opening a second one, together with the group tri-state (Fase 6); the **filter atom**, where both the control and the operator list are derived from `filterType` instead of a closed `switch`, and the panel it lives in became the 288px overlay inventory (Fase 7); **`GridFilterControl`**, the string-keyed registry that makes an application-declared filter first class through the obligatory `toFragment` + `describe` pair, so a filter with no column lands in the toolbar instead of being pruned (Fase 8, R15); and **Riga Zero** (Fase 9), described in the paragraph above. **Fase 10 adds no behaviour**: it is the delivery — the `AppDataGrid/Completa` scene, remounted on the mockup's own columns and rendered with the real engine (**fourteen leaf columns**, measured as `aria-colcount="14"` on the rendered table: twelve of data plus the checkbox column and the row menu; three header levels; five cell renderers declared through `cell` — status badge, initials avatar, progress bar, `it-IT` amounts, `gg/mm/aaaa` dates; group aggregates through `aggregatedCell` and the `<tfoot>` count and average through `aggregate`), next to the frozen `AppDataGrid/Mockup`; the list of deviations from that mockup, **thirty-four rows** each carrying `accettato`, `aperto` or `chiuso`; and the single measured registry of the fifteen requirements, all in `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md`. ⚠️ **Fase 10 closes nothing on the component, and the remount closed nothing either**: **nine** of those thirty-four rows are still open — **seven of the component** (rows 13, 14, 19, 20, 22, 27, 34), one of governance (row 10, the chip row of §11.3-A, which no signed decision covers) and one of the story (row 28, the missing initial sort, open because nobody has written why). The seven: the group label falls in the grouping column rather than the pinned one (row 13) and reads «Edilizia 78 righe» rather than «Edilizia (78)» (row 14) — ⚠️ **row 14 is listed above, and in the COMPONENT it is closed**: `adg-m5` made the parentheses unconditional in `parts/grid-group-row.tsx`, and no prop governs them; it keeps its `aperto` only because the count mirrors the plan's table. **Row 13 stays genuinely open**: the prop exists (`groupLabelPlacement="first-column"`) but the signature scene does not pass it; the status line is `sr-only` (row 20); the selection bar's count phrase is a fixed template (row 22); and the toolbar order is **half closed** (row 27) — `adg-m3` gave it the mockup's two groups and «Esporta» before «Schermo intero», so what is left is ONE swap away: WHERE the filters slot goes, which is a decision and not a change (see «The toolbar order, and the one decision still open»); ⚠️ **Rows 19 and 34 are listed above, and in the COMPONENT they are closed — `adg-m2` closed BOTH in one edit, because they were one root**: a single undefined `scopeTotal` switched off the visible figure (19) and made `aria-rowcount` fall back to the rendered rows (34); the paginated scene now reads «Righe 1-10 di 128» with `aria-rowcount="128"` over ten rendered rows, measured live. They keep their `aperto` in the count above only because that count mirrors the plan's table, whose reclassification is pending — the two numbers are not a disagreement about the component. The remedy is NOT a new prop — `resultTotal` exists only on `GridClientProps` (`app-data-grid.types.ts:1088`), the branch declaring `paginated?: never` (line 982), so passing it beside `paginated` is `error TS2322`, measured with `tsc --noEmit`. The scope is **DERIVED** instead: a grid that paginates itself already holds the figure. See «The scope on a self-paginating grid». ⚠️ The filter trigger carrying no count is **not** among them: that is row 18, and it is `accettato`. ⚠️ Nor are **the trailing skeleton rows**, and the reason is the one that took them off the list (row 24, reclassified `accettato` with the measure in hand): they are not missing — **they are the R4/P8 invariant on the `infinite` branch**, where an appending page keeps `replacingRows` false, so `dataRowCount` stays full while `skeletonRowCount` is positive and the skeletons ACCOMPANY the data instead of replacing it. They replace it only outside that branch. The signature scene is paginated, so the trailing pair is unreachable **by construction** there, not absent — and it is proved by two stories, `app-data-grid-infinite.stories.tsx :: GliScheletriStannoDOPOIDati` and `:: NessunVuotoNeiTreStati`. ⚠️ Nor is **the viewport 64px shorter than its content with three header levels** — that is row 25, **closed by `adg-m1`**, and both halves of the old sentence are now false: the input exists (`headerLevels` on `GridViewportHeightInput`, with `headerHeight * headerLevels` in `gridViewportHeight`, and `gridFrameHeight` no longer dropping inherited fields in silence) and the measured delta is **0** — `scrollHeight` 502 against `clientHeight` 502, where it was 64, with zero rows covered by the `<tfoot>`. ⚠️ But the remedy is a PACT WITH THE CALLER, not a behaviour of the component: see «Two height formulas» for why the full signature is written out there. ⚠️ **`chiuso` is a THIRD outcome value, not an `accettato` in disguise**, and rows 15, 17 and 25 carry it: they left the OPEN rows without joining the accepted ones. The difference is the whole point — «accepted» means the divergence from the mockup is still there and we are keeping it; «closed» means it is GONE and is nobody's work any more. The thirty-four therefore split **22 `accettato` + 9 `aperto` + 3 `chiuso`**, and this blockquote enumerates only the nine. Which task closed what is written in the table's own `chiuso da <task>`, and is deliberately NOT repeated here. The count above is the plan's own tally at the moment it was measured, and `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md` is the file that re-derives it: this paragraph does not, so that two documents cannot disagree about the same arithmetic.
|
|
13
|
+
>
|
|
14
|
+
> **Reach for `AppDataGrid` in new work.** Migrating a call site is mostly a rename — `scripts/codemod/rename-map.json` records name by name which old export maps to which new one; see "Coexistence with `DataTable`" below.
|
|
15
|
+
|
|
16
|
+
Three contracts are **copied from `DataTable`, never imported**, so that migrating a call site is a rename rather than a rewrite:
|
|
17
|
+
|
|
18
|
+
1. **The `cell:` signature**, arity inconsistency included.
|
|
19
|
+
2. **The server payload shape** (`GridServerState`), field-for-field identical to `ServerState`.
|
|
20
|
+
3. **The i18n type**, superset-assignable: anything satisfying `DataTableI18n` satisfies `GridI18n`.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Props
|
|
25
|
+
|
|
26
|
+
### AppDataGridProps<TData>
|
|
27
|
+
|
|
28
|
+
| Prop | Type | Default | Description |
|
|
29
|
+
| -------------------------- | --------------------------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
30
|
+
| `columns` | `GridColumnNode<TData>[]` | — | **Required.** Built with the `createGrid*Column` builders — never hand-written. From P5 the array may also contain **groups** (`createGridColumnGroup`), which add header rows without touching anything else. `GridColumnDef<TData>[]` stays assignable, so pre-P5 call sites need no change. |
|
|
31
|
+
| `data` | `TData[]` | — | **Required.** The rows. Client-side; every row is rendered unless `virtualized` is on. |
|
|
32
|
+
| `i18n` | `GridI18n` | `defaultGridI18n` | Labels. A `DataTableI18n` value is assignable here as-is — which is why every section added after P1 (`columnResize`, `selectColumnHeader`, `toolbar`) is **optional**, with the fallback from `defaultGridI18n`. |
|
|
33
|
+
| `debounceTime` | `number` | `200` | Milliseconds to wait before **confirming** a search or filter keystroke; `0` confirms immediately. Confirming — not merely emitting: while the window is open the table stays on the last state it reached instead of re-filtering on every key, and when it closes it filters **and** emits in one event. The typed character stays instant either way: what is deferred is the state that FILTERS, not the state the inputs show. Works **in both modes** — in client mode it buys the local re-filtering rather than requests, which is why it lives here and not inside `serverConfig`, which the client variant does not have. Only the continuous actions: sort, page, load-more and preset confirm at once and cancel a pending confirmation. Does **not** cover lifted badges — there the consumer reduces the rows, and `useGridFilters` publishes `committedBadges` for it. Wins over `serverConfig.serverDebounceTime`, the same delay under the name inherited from the frozen twin. |
|
|
34
|
+
| `sortMode` | `"single" \| "multi"` | `"multi"` | `"multi"` matches today's `DataTable`. Shift-click accumulates. |
|
|
35
|
+
| `maxSortedColumns` | `number` | `2` | Ceiling on simultaneous sorts, matching `DataTable`. The oldest is dropped past the cap. |
|
|
36
|
+
| `density` | `"compact" \| "default" \| "comfortable"` | `"default"` | Row height: 32 / 36 / 48 px. ⚠️ **BREAKING from `adg-c12`: this is the ONLY handle on density, and the grid renders no density control.** The `toolbar.density` key is gone from `GridToolbarSlots` and passing it is a compiler error. Row height is a decision of whoever mounts the table. Formally the prop is still "controlled when passed, uncontrolled when omitted", but with the control removed nothing inside the grid writes that reducer slice except `applyPreset` — so **omitting the prop freezes density at `"default"` for good**. To offer the switch to the end user, mount the public `GridDensityControl` in `toolbar.actions` or outside the table (see `AppDataGrid/Completa`, which puts it outside). |
|
|
37
|
+
| `onDensityChange` | `(density: GridDensity) => void` | `undefined` | **Not an emission** — density is view configuration, so it cannot refetch. ⚠️ **From `adg-c12` the toolbar no longer fires this**, because there is no toolbar density control any more: the remaining caller is **preset application**, which announces `preset.view.density`. Without that caller the prop would be public, documented and never invoked. A `GridDensityControl` you mount yourself reports through its own `onChange`, not through this prop. ⚠️ **Declared limit:** on a grid with `density` passed, a preset's own density cannot win — the render picks the prop — so the preset *announces* its density without imposing it. Who should win is an open product decision (`adg-c12`). |
|
|
38
|
+
| `onFullscreenChange` | `(fullscreen: boolean) => void` | `undefined` | Fires on entering and leaving full screen. Notification only, not a controlled mode: persisting it across sessions belongs to P10. |
|
|
39
|
+
| `filters` | `readonly GridBadgeFilter[]` | `undefined` | The active filter badges, **lifted above the grid** (P7). **Controlled when passed**: the grid stops keeping its own filter slice and reads these, which is what makes the chips and the panel work with no `<AppDataGrid>` in the tree. Build them with `useGridFilters` and pass the same value to the chips and to the grid. Absent = the grid uses its own slice, exactly as before P7. |
|
|
40
|
+
| `columnVisibility` | `Readonly<Record<string, boolean>>` | `undefined` | Column id → visible (P7). **An absent column is visible**, because absence means nobody said anything. Controlled when passed, like `filters`, and **the registry is one place**: this map is the only truth about which columns exist. |
|
|
41
|
+
| `paginated` | `boolean` | `false` | Paginated mode. Off by default. **Not available alongside `groupBy`** — it lives on the server variant of the union only. Pagination slices the rows _before_ grouping sees them, so a group would count, and `aggregatedCell` would sum, only the current page: measured at 6 rows out of 340, with another "Italy" reappearing on the next page. That is the same number-that-looks-like-a-total the union forbids for server sources. |
|
|
42
|
+
| `pageSize` | `number` | `10` | Rows per page when `paginated`. ⚠️ **Seeds the live page size and is inert afterwards** (the «rows per page» selector owns it from Fase 4 on). ⚠️ **The SKELETON count is capped at `1_000`** — `Math.min(livePageSize, 1_000)` — because that number feeds `Array.from({length})`: measured, `Infinity` raised `RangeError: Invalid array length` and `1e9` killed the process with `heap out of memory`. Data rows and the paginator are NOT capped, so above 1000 the loading page shows fewer placeholders than the page that is arriving. ⚠️ **`0` and non-integers are not validated here**, and `readGridViewPreset` refuses them: a grid mounted with `pageSize={0}` captures a preset that can never be restored (declared limit 6). |
|
|
43
|
+
| `onQueryChange` | `(state: GridServerState) => void` | `undefined` | Fires **only** on a query action (search, sort, filter, page). A view action can never reach it. **Mutually exclusive with `groupBy`**: the props type is a union, so the two together do not compile — see the `groupBy` row. |
|
|
44
|
+
| `virtualized` | `boolean` | `false` | Renders only the visible window of rows. Opt-in, so existing call-sites are unaffected. Needs a bounded viewport — see `viewportHeight`. |
|
|
45
|
+
| `viewportHeight` | `number` | `480` | Height in px of the scroll container. Applied **whenever it is passed explicitly**, and in any case when `virtualized`, `infinite` **or `groupBy`** is on (`viewportIsBounded`, `app-data-grid.tsx`). **Fase 6 added `groupBy` to that list**: a grouped grid without a bounded height grew with its content, so nothing could be recomposed when the selection bar appeared — and the scroll compensation of P9 could never fire either. Must be a number: a percentage against an auto-height parent resolves to zero. ⚠️ **From `adg-m3`, NOT passing it no longer means «content height» inside a stretched frame.** The scroll-area root now carries `min-h-0 grow` whenever the viewport is UNBOUNDED — exactly the case with no `viewportHeight`, no `virtualized`, no `infinite` and no `groupBy` — so it takes whatever vertical space its parent leaves. Measured defect on the template: the grid root stretched correctly to 617px (the consumer passes `className="flex min-h-0 flex-1"`) while the scroll area stopped ~84px short, and the residue stayed empty INSIDE the grid's own frame; the frozen table stretched by construction (`flex max-h-full min-h-0 flex-1` around an `h-full` `ScrollArea`), so this was a regression of the lane and not a choice. **`grow` and not `flex-1`, and that is the load-bearing half:** `flex: 1 1 auto` keeps the box at CONTENT height when the parent has no definite height (every Storybook scene that omits this prop), where `flex: 1 1 0%` would size it on a zero basis and collapse the frame. When this prop IS passed — or under `virtualized`/`infinite`/`groupBy` — the class is not emitted at all and the inline height stays the only source: `[data-grid-viewport].clientHeight === viewportHeight` holds word for word, and so do the budgets `gridViewportHeight` derives from it. |
|
|
46
|
+
| `infinite` | `GridInfiniteOptions` | `undefined` | Loads rows page by page as you scroll (P8). Absent = off. `{ hasMore }` — the consumer declares whether another page exists; the grid never infers it from a short page. **Wants `virtualized`**: without it every arriving row is attached to the DOM, and an infinite source is exactly the case with no ceiling. The grid asks for a page by emitting `onQueryChange` with `pagination.pageIndex` advanced by one — **the consumer appends**, the grid keeps no copy of the data. **⚠️ Ignored when `paginated` is on** — the two write the same `pageIndex` and together produce an empty table; see "Declared limits" under Infinite source (P8). |
|
|
47
|
+
| `preset` | `GridViewPreset` | `undefined` | The saved view to **apply** (P10). Applied whenever the object's **identity** changes — not its content — so re-passing a field-for-field equal preset applies it again, on purpose: «re-apply the view I am already looking at» is a legitimate gesture. One application raises `data-emissions` by exactly 1. ⚠️ **The grid now runs it through `readGridViewPreset` itself**, and until this change it ran it through nothing: the type is a COMPILE-TIME contract and `JSON.parse(localStorage.getItem(k))` is `any`, so a truncated payload used to reach `preset.view.density` inside an effect — a `TypeError` with the tree unmounted, i.e. the blank screen the declared error exists to replace. A payload the door refuses applies **nothing** (not the view half, not the query half), does not call `onDensityChange`, does not call `onPresetApplied`, and logs the `GridViewPresetVersionError` once with `console.error`. The grid stays on the view it already has — a RIGHT view, just not restored. ⚠️ **Declared limit — a refusal has no dedicated channel:** `onPresetApplied` NOT firing is the machine-readable signal, so a consumer who did not wire it only sees the console. An `onPresetError` prop is a feature nobody asked for. ⚠️ **The door is STRICTER than the type**, on five families of fields — see «Saved views» below. |
|
|
48
|
+
| `onPresetApplied` | `(preset: GridViewPreset) => void` | `undefined` | Fires **after** a preset has been applied, with the preset itself. It exists because half the view state lives outside the grid — P7 lifted filters and column visibility above it — and the grid cannot write what is not its own: it applies the preset to its own state to build the emitted payload, then asks the consumer to realign. `useGridFilters().restore()` does that without emitting again. |
|
|
49
|
+
| `groupBy` | `readonly string[]` | `undefined` | Groups rows by one or more columns, in order (P9). **Client only, and the compiler enforces it**: `AppDataGridProps` is a union, so `groupBy` alongside `onQueryChange` does not compile. Each entry consumes a level — `["country", "city"]` nests cities inside countries. A group's key is its **path**, so two "Nice" under different countries stay two groups. Expansion is **view** state: it never emits. |
|
|
50
|
+
| `initialOpenGroups` | `"all" \| "first" \| readonly string[]` | `"all"` | Which groups start open (C1). **Client variant only**, like `groupBy` — on the server variant it is `never`. Three forms: `"all"` (default, nothing is closed), `"first"` (only the **first first-level** group stays open; deeper levels are untouched, so inside a group that opens the subgroups are open), and a list of **paths** (exactly those stay open, plus the ancestors needed to reach them — asking for the "Roma" nested under "Italia" while "Italia" is closed would make the request invisible). A fourth form does not compile: the type is a closed union, not a `string`. **It is a seed of `groupExpansion`, not a mode** — that map records only the exception (`false` = closed, absent = open). **Re-application rule, in two halves.** _When:_ it re-seeds only when the **set of group paths changes** — so a user's toggle never triggers it (the group tree is built from rows + `groupBy` and does not depend on expansion), and neither does a re-sort (the path set is compared as a set, not a sequence). _What it does when it fires:_ **it never changes the state of a group that still exists.** Every group somebody has already spoken about — the user by toggling it, or an earlier seed — keeps what it had; `initialOpenGroups` is applied only to paths that are **new**; groups that vanished lose their entry. Both halves are needed: the set changes because of **one** group, so a re-seed that redid everything would charge every group for it. Measured before the fix — with `"first"` and four categories, opening 2 and 3 by hand and then typing a search term that removed every row of category 4 slammed 2 and 3 shut. The flip side of the same guarantee: `"first"` is **not** re-applied to a group that already has a state, so if the first group disappears the second does not open by itself. Changing the prop's value on a mounted grid does nothing on its own — it is "initial" in the sense of `initialState`: declared, not applied. |
|
|
51
|
+
| `initialState` | `GridInitialState<TData>` | `undefined` | Initial filters, sorting, pagination, search term and column visibility, in the shape corpus call-sites already wrote (the twin of `InitialTableState`/`DataTableState`) — P10. **Read once at mount**: changing it afterwards does nothing, like in the frozen twin. To restore a view on a mounted grid use `preset`, which is a different prop because it is a different thing — one is declared, the other is applied. **Server variant only**, so not available alongside `groupBy` — see «Cosa i tipi rifiutano». |
|
|
52
|
+
| `className` | `string` | `undefined` | Extra classes on the grid container. |
|
|
53
|
+
| `id` | `string` | `undefined` | DOM id. |
|
|
54
|
+
| `data-testid` | `string` | `undefined` | Test hook on the container. |
|
|
55
|
+
| `loading` | `boolean` | `false` | A request is in flight. Renders skeleton rows at the same height as data rows, and suppresses **both** outcomes without data — the empty one and, from Fase 5, the error one: saying «no results» to someone who is waiting is the quickest way to make them close a page that would have shown their data a second later, and an error from the previous attempt above the skeletons of the current one describes the past. From Fase 5 loading is one of the **three** outcomes rendered inside the table, and above it the frame is silent too: no `<tfoot>`, no paginator. **From P8 skeletons and data coexist**: when the in-flight request is page N>0 of an infinite source the skeletons are **appended** after the data; otherwise (a new sort, filter, search, or the first load) they **replace** it, because the rows on screen belong to a query that is no longer the current one. |
|
|
56
|
+
| `emptyComponent` | `ReactNode` | `undefined` | Replaces the default "no results" message **on screen only**, and only for the EMPTY outcome — the error outcome has a surface of its own with its own i18n strings, and `emptyComponent` does not reach it (from Fase 5 «the state without data» is no longer one thing). From Fase 5 it renders as a **descendant of the `[data-grid-empty]` cell inside the `<tbody>`**, not in a `<div>` beside the table. The live region keeps announcing `i18n.notFoundMessage`, so with a custom node the screen and the voice say different things — see "Declared limits" below for why, and for what to do about it. |
|
|
57
|
+
| `actions` | `GridActions[]` | `[]` | The buttons the grid dresses itself, `{label, icon, onClick, data-testid}` — rendered **INSIDE `[data-grid-toolbar]`**, in its `.ml-auto` right group, between «columns» and the `toolbar.actions` slot. ⚠️ **MOVED by `adg-m3`, and this row used to say the opposite**: until then they were their own `role="toolbar"` row, the FIRST child of `[data-app-data-grid]`, ABOVE the toolbar. The move is not cosmetics — this prop is the compat RENAME of `DataTableProps.actions`, aimed at the corpus the codemod covers (the 297 files this document counts elsewhere), and the frozen table rendered those buttons inside its OWN bar (`tables/data-table/components/data-table-actions.tsx`), so a consumer who migrates by renaming has to find the same bar. That `AppDataGrid` is ABSENT from the last published bundle is what made the move free: no layout already in production moves. ⚠️ **TWO OR MORE COLLAPSE INTO ONE trigger** — `[data-grid-actions-trigger]`, named `i18n.tableActions.actions`, with the commands inside `[data-grid-actions-panel]`; **ONE action renders inline**. Also the frozen shape, and the reason is measured: that right group is 24px tall and already holds «columns» and the full-screen toggle, while the real corpus (`UserTable` in the template) passes THREE — three more buttons there wrap a bar that is `flex-wrap`, collapsed it grows by ONE trigger. ⚠️ **The dress is the bar's own `gridToolbarTriggerClass`** (`variant="ghost"`, h-6, text-xs), NOT the frozen `variant="default"`: the two neighbours are ghost triggers, and the lane already carries a written decision against the primary variant (its hover sheen uses `rounded-[inherit]`, an arbitrary value `check-grid-css.mjs` refuses). **Declared cost:** an «Add» action loses the emphasis it had in the frozen bar. `hideActionsRow` still wins over a populated list. |
|
|
58
|
+
| `hideActionsRow` | `boolean` | `false` | Hides the `actions` buttons even when `actions` is populated. ⚠️ **SCOPE CHANGED from `DataTable`, where it hid the WHOLE toolbar** (sorting, filters, reset, search, column visibility, actions). Here it hides only the `actions` buttons: `<div data-grid-toolbar>` renders unconditionally and keeps the search field, its `×` and the full-screen trigger. A call-site that used it to hide the entire bar gets a bar. Nailed by `app-data-grid-fix-guscio.stories.tsx :: LaPortataDiHideActionsRow`. ⚠️ **From `adg-m3` there is no ROW left to hide.** The buttons live inside the toolbar's right group, so what this prop suppresses is `[data-grid-actions-row]` and its contents — not a band of the frame, and not the toolbar, which renders either way. The semantics did not change (it still wins over a populated `actions`, and the story above still nails it); only the node it removes has moved. It keeps its `Row` name for the same reason the hook does — see `[data-grid-actions-row]` in the DOM contract. |
|
|
59
|
+
| `checkable` | `boolean` | `false` | Adds the selection checkbox column (to `colgroup`, `th` and `td` alike). |
|
|
60
|
+
| `rowSelection` | `Record<string, boolean>` | `undefined` | Controlled selection; internal when omitted. |
|
|
61
|
+
| `getRowId` | `(row, index) => string` | `undefined` | The identity of a row, declared by the consumer, forwarded to TanStack. **Absent = the identity is the position**, which is TanStack's default and shows up in the selection: in infinite mode a `restart` (a re-sort, a filter change) rebuilds the list, and a selection pinned to indices stays pinned to the same _indices_ — i.e. to different records than the user picked. The ticks are still there, still look right, and point at something else. Opt-in: passing it is how you get record identity. Return a key from the data (`(row) => row.code`), never `String(index)`. |
|
|
62
|
+
| `onRowSelectionChange` | `(selection) => void` | `undefined` | Fires on every selection change. |
|
|
63
|
+
| `onCheckedRowsChange` | `(rows: TData[]) => void` | `undefined` | The selected rows, already resolved to data. |
|
|
64
|
+
| `rowClassName` | `string \| ((row: GridRow<TData>) => string)` | `undefined` | Per-row classes. The callback form is resolved internally. |
|
|
65
|
+
| `toolbar` | `GridToolbarSlots` | `undefined` | **Fase 3, live.** The shell, opt-in **per slot**: `search`, `fullscreen`, `filters`, `columns`, `actions`. ⚠️ **`density` is NOT in that list from `adg-c12`** — the key was deleted, not moved to the opt-in family, and `toolbar={{ density: {} }}` is a `TS2353`. The "always rendered" family is therefore **two** keys, not three. Each key is a **configuration object** whose PRESENCE turns the slot on and whose optional `render` replaces the node the grid would draw. `search`/`density`/`fullscreen` are rendered **always** — declaring them only lets you substitute the node, and omitting them does not switch them off (the P6 rule is not retracted behind a prop). `filters`/`columns`/`actions` are **absent by default**, and the three use three **different** conditions: `columns` needs the key **and** a writable visibility map (see `onColumnVisibilityChange`); `filters` needs the key to carry `render` or `onOpen`; `actions` renders only its `render`, with no default node. ⚠️ **Not to be confused with the top-level `actions` PROP**, which from `adg-m3` lands in the SAME `.ml-auto` group, immediately BEFORE this slot: that one is the compat rename of `DataTableProps.actions` and the grid dresses its buttons itself; this one is the consumer's own node and the grid draws nothing for it. A slot is deliberately not a `ReactNode`: `ReactNode` admits `boolean` and `string`, so `toolbar={{ search: false }}` would compile and switch search off. `search: false`, `search: "si"` and `search: { render: () => null }` are all compiler errors, proven by the **three** probes in `type-probes/shell-slots.probe.tsx` rather than by this sentence — the first two guard the **property type**, the third the **return type** (`search`/`density`/`fullscreen` use `GridToolbarAlwaysSlot`, whose `render` returns `ReactElement`, not `ReactElement | null`). ⚠️ **The type closes the form, not the intention, and that is a declared limit:** `() => null`does not compile, but`() => <></>` does and paints nothing, so a substitute node that renders nothing stays writable — excluding it would mean inspecting what a function returns, which a type does not do. |
|
|
66
|
+
| `selection` | `GridSelectionSlots` | `undefined` | **Live from Fase 6.** Configures the **page-selection bar** — the band that appears **above** the scroll area (right after the filter band, the mockup's order) as soon as at least one row is ticked, and that does not exist at all while none is. ⚠️ **It used to appear UNDER it; it moved on 2026-09-03**, and the move retires a named promise — see "Page selection" below. `mode` is the one-member union `"page"` because selection is page-only (no promotion to the whole result set), which makes that decision a compile error instead of a note. `actions` is `readonly GridSelectionAction[]` — the bulk actions rendered inside the bar (`label` **without** a number, optional `icon`, `destructive?: boolean` mapped to the shared `Button`'s `destructive` variant, and `onSelect(rowIds)` receiving the **page** ids). `summary` replaces the bar's **totals band** — not the bar, and not the count: the count is the bar's accessible name via `aria-labelledby`, so a slot that replaced it would leave the group unnamed. **`summary` still passes no arguments, and that is now a decision rather than an open question**: the two things it could pass — the selected rows and their aggregates — the consumer already has from `onCheckedRowsChange` (or the `rowSelection` it controls) and from its own `aggregate` functions, so passing them here would open a second channel for the same two numbers. |
|
|
67
|
+
| `pagination` | `GridPaginationSlots` | `undefined` | **LIVE from Fase 4.** `pagination.pageSize` turns the paginator ON **and** seeds the live page size. **The precedence is decided** and resolved in ONE place (at the `useGridEngine` call): `initialState.pagination.pageSize` > `pagination.pageSize` > the top-level `pageSize` prop. **On the CLIENT branch the paginator is a read-out, not a control**: `paginated` is `never` there and the types carry no page channel, so it prints the range and the rows clause from the declared `resultTotal` and renders NO page numbers, NO first/prev/next/last and NO size select — those commands could change nothing but the wording. **It does not coexist with `groupBy`**: the render path excludes it, because grouping × pagination is the pair declared unrepresentable by `paginated?: never` (line 982) on the branch that admits `groupBy`. |
|
|
68
|
+
| `initialFullscreen` | `boolean` | `false` | **Fase 3, live.** Seeds the fullscreen state at the **first** render, so a call site that mounts with it finds `role="dialog"` immediately rather than after an effect. It is an `initial*`, not a controlled `fullscreen`: it seeds and then stops governing, so no consumer has to resynchronise a state it does not own. |
|
|
69
|
+
| `onColumnVisibilityChange` | `(columnVisibility: Readonly<Record<string, boolean>>) => void` | `undefined` | The missing half of `columnVisibility`, and the twin of `onDensityChange`. Until Fase 3 nothing **inside** the grid wrote that map — the panel was consumer-mounted and wrote it itself — so the controlled half existed without a callback. From Fase 3 the `toolbar.columns` slot lives inside the grid and dispatches `view/column-visibility` (the column menu's hide item did too, until the `⋮` menu was removed); but when `columnVisibility` arrives as a prop the engine **overrides** that reducer slice with the prop, so the dispatch would be invisible. With this callback the grid **asks** the owner of the map for the change instead of writing where nobody reads. Receives the already-applied map, in the slice's convention: an absent key means visible. ⚠️ **Omitting it while `columnVisibility` is passed switches the control off.** In that combination nobody can write the map — the dispatch is suppressed and the request has no recipient — so the «columns» trigger is **not rendered** at all, rather than accepting a click and snapping back in the same commit. Same rule the phase already applies to `toolbar.filters` without `onOpen`: a button that does nothing is worse than a button that is not there. Measured: with the prop and no callback `[data-grid-toolbar-columns]` is not in the DOM; adding the callback brings it back. (It used to switch off TWO controls, the second being the `⋮` menu's hide item, removed with the menu.) |
|
|
70
|
+
| `onColumnWidthsChange` | `(columnWidths: Readonly<Record<string, number>>) => void` | `undefined` | **The capture half of `columnWidths`, and until this prop there was none.** A width was always restorable through `preset.view.columnWidths`, and never readable: `view/column-width` lives in the reducer and no prop carried it back out, so a persistence consumer saved the widths frozen at their first value forever — which is written down as the literals `columnWidths: {}` / `columnOrder: []` in the phase's own reference harness. **Not an emission** — a width is view configuration, so it cannot refetch: `view/column-width` is a `GridViewAction`, `onQueryChange` does not appear in `applyView`, and `data-emissions` does not move. Receives the **already-applied, whole** map, in the slice's convention, exactly like `onColumnVisibilityChange`. ⚠️ **The map is PARTIAL, and the partiality is the value:** a column never dragged is **not a key**, and that absence is what `parts/grid-table.tsx` reads to decide which columns are elastic and take part in slack distribution. Completing it would switch slack distribution off at the first save AND emit a value different from what `preset.view.columnWidths` stores — the round trip would not come back. ⚠️ **One call per commit, not one per pixel:** between `pointerdown` and `pointerup` the width is written straight to the `<col>` and consolidated into state once, on release; `pointercancel` does not consolidate, so it does not notify. ⚠️ **Declared limit — the keyboard commits once per keypress**, so a held arrow key yields one notification per OS key-repeat. The grid does not smooth it: `serverDebounceTime` is declared for query _emission_, and a view action has no emitter to protect. Debounce on your side if you POST per notification. ⚠️ **Declared limit — a zero-delta gesture notifies too, and the notification is faithful.** `pointerup` commits the seeded start width whatever it is, so a still click on the handle (a mis-click, or the click that focuses the separator before using the arrows) dispatches the rendered width and the column leaves the elastic set from then on. This prop does not filter it: the dispatch is pre-existing P4 behavior, so suppressing only the notification would leave the consumer's copy diverging from the reducer slice — the one thing worse than an extra notification. ⚠️ **Declared limit — the width handed over is the RENDERED one**, i.e. post-`distributeGridColumnSlack`, so the first notification for a formerly elastic column carries a number nobody typed and nobody dragged to: well-defined but **viewport-dependent**, which means a view captured on a 1600px window and restored on a 1200px one pins a column at a width that window would never have given it. Restore itself is safe — `columnWidths[id] ?? header.width ?? gridColumnWidth.default`, then clamped. ⚠️ **Declared limit — it does NOT fire when a `preset` is applied**, deliberately: `applyPreset` dispatches `view/preset` straight to the reducer and never reaches this handler. Firing there would close a write-back loop (load the saved view → `preset` → callback → the consumer POSTs what it just GET-ed). Restoration is announced once and wholesale through `onPresetApplied`. The asymmetry with `onDensityChange`, which _does_ fire on a preset, is not a precedent — that one was rescued there because `adg-c12` removed its last caller. ⚠⚠ **And therefore the consumer MUST re-seed its captured copy from `onPresetApplied`.** `view/preset` replaces `columnWidths` wholesale and no capture callback announces it: drag `city` to 400, then restore an older view whose `columnWidths` is `{}` — the grid goes back to all-elastic while the consumer's copy still says `{city:400}`, and the next save, with nobody having touched anything, writes it back and undoes the restore in silence. One line in `onPresetApplied` fixes it. ⚠️ **No controlled half:** there is no `columnWidths` prop, because `preset.view` already imposes widths in one tick with one emission. Consequence: `preset` is guarded by object **identity**, so restoring the same saved view twice needs a new object. |
|
|
71
|
+
| `onColumnOrderChange` | `(columnOrder: string[]) => void` | `undefined` | **The capture half of the column order, and the twin of `onColumnWidthsChange`.** `view/column-order` has a single dispatch in the whole grid and had no way out; a `GridColumnRegistryPanel` you mount yourself has always had its own `onColumnOrderChange`, but an order decided there does not reach the grid — there is no `columnOrder` prop. **Not an emission** — column order changes nothing the server needs to know, and `view/column-order` is not a `GridQueryAction`. Receives the **new, whole** order, not the move. `string[]` and not `readonly string[]` on purpose: `GridViewPresetView.columnOrder` is `string[]`, so a readonly array is `TS4104` on assignment and would force `[...columnOrder]` in every consumer; the widths map stays `Readonly<Record<…>>` because TypeScript ignores the modifier there — the asymmetry is the language's, and it is the same one the panel already has. ⚠️ **Never fires on a non-event**, and not because this point guards: the panel already rejects a move past the end of the list, one that crosses a pinned block, and an identical order. ⚠⚠ **Declared limit — without the IN-GRID registry this prop can never be invoked, and passing `toolbar.columns` is NOT enough.** The node that wires it renders only if `toolbar.columns` is present, does **not** carry its own `render` (a consumer's panel wins the precondition, deliberately), **and** `canWriteVisibility` is true — i.e. `columnVisibility` is absent, or arrives together with `onColumnVisibilityChange`. The combination «controlled visibility without its callback» switches the trigger off entirely, and with it the reorder. Pass the three together, or this is a prop that cannot fire. ⚠️ **Declared limit — it does NOT fire when a `preset` is applied**, same reason, same loop, same restore channel as `onColumnWidthsChange`, and the same **obligation** to re-seed the captured copy from `onPresetApplied`, because `view/preset` replaces `columnOrder` wholesale too. ⚠️ **Declared limit — the order is a FLAT list that does not say whose an id is.** The engine sorts **siblings** inside their group, so a saved order whose move crosses a group boundary is a visible non-event. Pre-existing P7 behavior, true of the captured value as of the applied one. ⚠️ **No controlled half:** the order lives only in the reducer, and `preset.view.columnOrder` is the way to impose it. The panel tolerates a **partial** order — it appends the ids the saved order does not name — so a view saved before a column existed does not lose it. |
|
|
72
|
+
| `resultTotal` | `number` | `undefined` | **CLIENT BRANCH ONLY — Fase 4.** How many rows exist IN TOTAL, i.e. the figure the paginator prints after «of» («Rows 1-10 of 128») and the chip row's clause reads. NOT the number of rows rendered: in client mode `data` may already be a page, and `data.length` would say 10 where the scope is 128. The server-branch twin is `serverConfig.totalItems` — the two never merge, and which one you pass is decided by the branch. Omitted, the scope equals the rows actually rendered — **except on a grid that paginates itself**, where from `adg-m2` it is derived: see «The scope on a self-paginating grid». Passing it (or `pagination.pageSize`) is also what turns the paginator on on the client branch, where `paginated` is `never`. |
|
|
73
|
+
| `groupLabelPlacement` | `"grouping-column" \| "first-column"` | `"grouping-column"` | **CLIENT BRANCH ONLY — M5.** Where a group row's label, chevron and count land. They are ONE node and move together, so this prop picks the cell all three fall into. `"grouping-column"` (default) puts them in the cell of the column that groups **at that level**; `"first-column"` puts them in the **first rendered column**, which is the mockup's shape. ⚠️ **It places, it does not pin**: the identity sits in a frozen left band that does not scroll away ONLY if that first column declares `pinned: "left"`. On an unpinned first column the label scrolls away with the body — the play passes because the fixture's first column happens to be pinned. **The default reproduces today's behaviour on purpose:** flipping it would move the label in every consumer's grouped grid without anyone asking, so a grid that does not pass this prop renders exactly what it rendered before. **A closed union of two forms, never a column id** — a free id would be an unverifiable `string`, and pointing it at a column that does not exist (or is hidden) would do nothing and say nothing, leaving the grid on the default while the author believes they moved it. **It hides no column, and that is why the prop exists:** moving the label left used to require *hiding* the grouping column — a group row falls back to the first visible column when its own is gone — which drops the rendered leaves by one and makes the «fourteen columns» clause of the complete scene false. `"first-column"` unhooks PLACEMENT from VISIBILITY: every column stays, and the grouping column's cell goes back to the common rule — its own aggregate if it declares one, otherwise empty (R11: the grouping value appears once, and it is the label that says it). **Server variant: `never`**, next to `groupBy` and `initialOpenGroups` — a grid that does not group has no group label to place, and without the twin this prop would be a silent no-op instead of a type error. |
|
|
74
|
+
| `error` | `boolean` | `false` | **Fase 5.** The request FELL: the data exists and did not arrive. It is the PREDICATE, and `onRetry` is not one — without it a failed fetch and a filter that catches nothing are the same state for the grid (`loading={false}`, `data={[]}`), and the reader is told «no results» where they should be told to retry, so they change the filters instead. Lives on **BOTH branches** of the union. It **wins over the empty outcome** (a failed request has zero rows, so both predicates would be true) and, like the empty one, does **not** show while `loading`: an error from the previous attempt above the skeletons of the current one describes the past. |
|
|
75
|
+
| `onRetry` | `() => void` | `undefined` | **Fase 5.** What to do when the reader asks to retry after a failed load. Governs the «Retry» control **only** — whether the error outcome renders is `error`'s job: absent, the error outcome is a message without a button, which is the honest shape when the caller has nothing to relaunch. Lives on **BOTH branches** of the union — a request fails on the server branch too. One click invokes it **exactly once**; the grid keeps no attempt counter, because it holds no state. |
|
|
76
|
+
| `filterControls` | `GridFilterControlRegistry` | `undefined` | **Fase 8 (R15).** The registry of the filters the APPLICATION declares — `Readonly<Record<string, GridFilterControl>>`, keyed by `GridFilterControl["type"]`. The grid does **not** render a registry entry's `Control`: the control that writes a custom filter's value is rendered by the application, where the filter is born — `createGridCustomFilterBadge` is the only place `describe` and `toFragment` are ever called, and from there the badge carries its own text and fragment. The grid uses the registry for ONE measurable thing: recognising a `kind: "custom"` badge whose `customKey` is **not** a key of the registry, and marking its chip with `data-grid-toolbar-custom-filter-unknown` instead of showing it like the others — a badge that did not come from the registry is a defect, and it is made noisy rather than plausible. Absent, no chip is marked. Lives on **BOTH branches** of the union. |
|
|
77
|
+
| `onRemoveFilter` | `(id: string) => void` | `undefined` | **Fase 8 (R15).** Removes ONE filter without a column, by badge `id` — not by `customKey`, since two custom filters of the same type share the registry key and only the `id` tells them apart. **It is the door that turns the dashed toolbar group on:** without a handler the group is not rendered at all, because each custom filter's «×» is a real, named `<button>` and a button without a handler is a dead command. It is deliberately **not** on the `toolbar.filters` slot: filters without a column exist independently of opening the panel, so the group renders next to the «Filters» trigger and OUTSIDE its ternary — a consumer who does not declare the slot, or who replaces the trigger with their own `render()`, still gets the group. **Executable precondition: it also needs `filters` lifted — and since R28 that holds for ONE of its two readers, not both.** ⚠️ This row used to say «the grid has no way to remove a badge from its own internal slice — the `query/filters` action exists in the reducer and nothing dispatches it». That is FALSE since R28: `clearFilterRowBadge` (`app-data-grid.tsx`) dispatches exactly `query/filters` with the badge filtered out, and it is the door behind the Zero Row's clear «×». The sentence is corrected, not kept. The two readers now split: for the **dashed group of column-less filters** the precondition still holds, because that group is a pure passthrough with no branch that writes the internal slice, so `onRemoveFilter` without `filters` would give a «×» that announces itself, takes focus, fires the handler and changes nothing on screen — which is why it renders only when BOTH are present. For the **Zero Row's «×»** it does not: `clearFilterRowBadge` branches on where the data lives, so with filters NOT lifted it removes the badge from the internal slice itself, and the condition governing that «×» is «internal slice OR `onRemoveFilter` wired», which is strictly weaker. ⚠️ **And with `filters` NOT lifted, `onRemoveFilter` is never called even if you wired it** — the Zero Row's «×» writes the internal slice directly and does not route through your handler. So a caller that wires it expecting to observe every removal will miss those: the handler is the door for the LIFTED case only. (The two paths branch on different conditions — `clearFilterRowBadge` on where the data lives, `applyFilterRowPatch` on which callback is wired — which is a known open item, not a contract.) Whoever lifts the filters passes `filters.badges` to `filters` and `filters.removeBadge` here — the same `useGridFilters` instance. Lives on **BOTH branches** of the union. |
|
|
78
|
+
| `filterMode` | `"row" \| "panel"` | `"panel"` | **Riga Zero.** WHERE the filters live. `"panel"` (the default) is the inventory panel: an overlay anchored to the toolbar's «Filters» trigger. `"row"` is the ZERO ROW — a FOURTH `<tr>` of `<thead>`, one control per column, inside the columns. **The default is `"panel"` and it is a decision, not convenience:** the zero row is the new mode and the panel the one already delivered and already measured, so a default on the other one would turn red every existing scene that counts `<thead>` rows without anyone having touched it. Whoever wants the row asks for it. **It is not a switch.** The library renders no control that moves between the two modes: that choice belongs to whoever MOUNTS the component (a story, an application setting), because a switch inside the grid would mean the grid owns a state the caller already declares as a prop — two sources for one thing. ⚠️ **In `"row"` the row exists only while the toggle is OPEN — changed on 2026-09-04 (R13).** Until then the row was rendered ALWAYS and the «Hide filter row» button only took its CONTROLS out of sight, because unmounting the `<tr>` drops every body row by `gridFilterRowHeight` px while the frame stays put — the same pixel under the pointer changing command. That price was PAID on purpose: the residue (a 36px row with nothing in it, plus a chip band put there to stand in for it) cost more than the jump, and the button's name promises to remove the row. Reserve the space with `gridViewportHeight({ …, hasFilterRow: filterMode === "row" && open })`, reading `open` from `onFilterRowOpenChange`; deriving it from the MODE alone now promises 36px that a closed row does not occupy. Lives on **BOTH branches** of the union. |
|
|
79
|
+
| `onAddFilter` | `(badge: GridBadgeFilter) => void` | `undefined` | **Riga Zero.** A cell of the zero row creates a filter for a column that had none. The badge arrives **already built** by `createGridFilterBadge` — the one and only constructor, the same the inventory panel uses — so wire it straight to `useGridFilters().addBadge`, the very function you also hand to `GridFilterPanel.onAdd`: the two surfaces then share one store and «both roads produce the same state» is a fact of the wiring, not a coincidence between two implementations. **Required whenever `filters` is lifted**, and the grid enforces it by omission: with lifted filters the grid has no way to write them itself (the reducer would be overwritten by the prop on every render), so without this callback the cells of the zero row stay EMPTY — the row is still there, the height still reserved, but no control is offered rather than a named, focusable target that fires a handler and changes nothing. With filters NOT lifted it is unnecessary: the grid writes its own slice. Lives on **BOTH branches** of the union. |
|
|
80
|
+
| `onUpdateFilter` | `(id: string, patch: Partial<GridBadgeFilter>) => void` | `undefined` | **Riga Zero.** A cell of the zero row changes the operator or the value of a filter that already exists. Same pairing as `onAddFilter` — wire it to `useGridFilters().updateBadge`, the same function `GridFilterPanel.onUpdate` gets — and the same rule: required whenever `filters` is lifted, or the cells stay empty. Lives on **BOTH branches** of the union. |
|
|
81
|
+
| `statusRowVisible` | `boolean` | `false` | **M4.** Renders the status line ON SCREEN as well. `[data-grid-live-region]` has existed since P8 and carries `sr-only`: it says «128 rows. 0 filters active. 0 rows selected» and only a screen reader receives it. Turned on, the SAME string — no second sentence, no second text node — is rendered on screen inside a frame carrying a megaphone icon, so **a reviewer can read the text a screen reader would receive instead of approving the a11y on trust**. **Off by default, and that is not caution:** `sr-only` is `position: absolute`, so today's node is NOT a flex item of the root — it contributes neither height nor the frame gap — while turning it on by default would add an in-flow child to every existing scene. **It does NOT open a second live region**, and that invariant is what the prop must not touch: the frame carries no `role` and no `aria-live`, the ICON sits OUTSIDE the region, and the region keeps one text node and zero element children. Copying the mockup's announcement literally — a `role="status"` container holding the icon plus a `<span>` — would take the region count to TWO and have the sentence announced in pieces. **Declared limit:** `gridFrameHeight` has no addend for this line, so on a bounded frame the root gains an in-flow child the formula does not reserve; whoever budgets the height of the whole component adds it themselves. Lives on **BOTH branches** of the union. |
|
|
82
|
+
| `onFilterRowOpenChange` | `(open: boolean) => void` | `undefined` | **R13.** Fires when the «Hide filter row» / «Show filter row» toolbar button is pressed, with the NEW state: `true` = the zero row is NOW mounted. ⚠️ **The toggle starts OPEN** — the grid holds it as `useState(true)` — so a caller mirroring it must seed its own state to `true`; seeding `false` gives a viewport short by `gridFilterRowHeight` px from mount that nothing ever corrects, because the first announcement only arrives on the first click. The toggle's state stays LOCAL to the grid — it is a reading preference of this instance, like an open column menu — but since R13 it has a GEOMETRIC consequence the caller can no longer derive from `filterMode` alone: **hiding the row UNMOUNTS the `<tr>`**, so `<thead>` is `gridFilterRowHeight` px shorter. Whoever budgets the height passes `hasFilterRow: filterMode === "row" && open` to `gridViewportHeight`, and this callback is where `open` comes from. Without it the budget stays the one for a row that is present — 36px of viewport promised and not rendered. **It is NOT a controlled prop, and the limit is declared:** there is no incoming `filterRowOpen`; the grid does not accept having its own toggle imposed on it, which would be two sources for one thing. This is the read-only channel. **Omitted by default, and omitting it moves nothing.** Lives on **BOTH branches** of the union. |
|
|
83
|
+
| `onSelectionScopeChange` | `(scope: GridSelectionScope) => void` | `undefined` | **M10.** Announces that the SCOPE of the selection changed, with the new value: `{ scope: "page" | "all", rowIds, count }`. ⚠️ **It exists because `onCheckedRowsChange` cannot say it, and without this channel the grid would publish TWO diverging numbers.** With the selection promoted the bar reads «128 rows selected» while `onCheckedRowsChange` keeps emitting the TEN rows of the page — which is correct, those are the rows the grid owns — so a consumer rendering its own count outside the grid would print 10 next to a 128. The scope arrives from ONE place, and it is the same object the bulk actions receive. ⚠️ **`rowIds` are ALWAYS the page's**, even at scope `"all"`: promoting does not conjure 118 ids out of nothing. Whoever reads `rowIds` at scope `"all"` is reading a SUBSET, and `scope` is the field that says so BEFORE they delete it. `count` is the only field that changes with the scope, and it is the figure every bar button shows. ⚠️ **It is NOT a control:** the grid owns the scope and this callback ANNOUNCES it — there is no prop to impose it from outside, because promoting is a gesture of whoever is looking. Same shape as `onFilterRowOpenChange`. ⚠️ **It reports GESTURES, not derivations — and the case that diverges is «offered, promoted, then withdrawn».** It is called only by the gesture that changes the scope. If the caller stops offering the promotion while the scope is `"all"` — dropping `resultTotal`, or setting `selection.mode` back to `"page"` — the grid goes back to composing `"page"` and the page count, and **nothing announces it**: the last notification said `all:128`, and a consumer that stopped there would act on 128 while the grid knows ten. This is **not** repaired with an effect, deliberately: an effect watching the derived scope would fire at mount and on every re-render that changes the total, announcing changes nobody asked for. The limit is DECLARED — whoever reads this callback must treat the offer's preconditions as part of the contract, not just the last value received. Withdrawing the offer is a change the consumer made itself. ⚠️ **It does not fire if the promotion is not OFFERED.** Without `selection.mode: "all"` the scope cannot change, so a page-scoped grid never calls it: receiving it once at mount with `scope: "page"` would be an event describing no change. It also needs the result total to be KNOWN (`resultTotal`, `serverConfig.totalItems`, or a non-server `paginated` grid): a «Select all undefined rows» button is worse than no button, so with an unknown total the control is not rendered and the scope stays `"page"`. ⚠️ **The announcement is INSIDE the gesture, never in an effect that watches state** — an effect would fire at mount and on every re-render that changes the total, announcing a change nobody asked for. Lives on **BOTH branches** of the union: promotion makes sense in server mode, where the page is a slice, and in client mode when `data` is already a slice loaded by the caller. |
|
|
84
|
+
|
|
85
|
+
**Selection never refetches.** It is held outside both state slices — it is not `query` (no refetch) and not `view` (not configuration) — so `data-emissions` is unchanged by selecting rows. There is a spec for it.
|
|
86
|
+
|
|
87
|
+
> These nine props were added in Fase 2 after the codemod measured that they blocked most of the corpus (`loading` alone: 246 of 297 files) while appearing in **no** phase of the migration plan. They are copied name-for-name and signature-for-signature from `DataTableProps`, which is what keeps the codemod a rename.
|
|
88
|
+
|
|
89
|
+
### Counting props — the paginator, and where each figure comes from (Fase 4)
|
|
90
|
+
|
|
91
|
+
Every figure of SCOPE — the paginator's range, the chip row's clause, the count on the «Filters» trigger — is derived ONCE by `gridResultScope` (`grid-result-scope.ts`, one definition in the whole lane) and read from the SAME object by the three surfaces. Nothing recomputes it; a `Math.ceil(total / pageSize)` written in a surface is the second derivation the phase exists to forbid.
|
|
92
|
+
|
|
93
|
+
| Prop | Type | Default | Description |
|
|
94
|
+
| ---------------------------- | ------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
95
|
+
| `pagination.pageSize` | `number` | `undefined` | Turns the paginator ON **and seeds the live page size** — precedence `initialState.pagination.pageSize` > `pagination.pageSize` > `pageSize`, resolved in ONE place at the `useGridEngine` call. (It used to be a switch that set nothing: `pagination={{ pageSize: 25 }}` rendered «Rows 1-10 of 128» and «10 per page», so the 25 did nothing and said nothing. `TagliaDalloSlot` is the spec.) Needed on the CLIENT branch, where `paginated` is `never`: there the paginator renders and DECLARES the scope but does not slice (`getPaginationRowModel` is mounted only by `paginated`) and does not navigate — see `navigable` below. Never rendered together with `groupBy`. |
|
|
96
|
+
| `pagination.pageSizeOptions` | `readonly (10 \| 25 \| 50 \| 100)[]` | `[10, 25, 50, 100]` | The sizes the «rows per page» select offers (decision 17). A closed union, not `number[]`: a size the select cannot render fails to compile instead of doing nothing. |
|
|
97
|
+
| paginator `navigable` | derived, not a prop | — | Whether changing page is OBSERVABLE: `paginated \|\| serverConfig !== undefined \|\| onQueryChange !== undefined`. False on the client branch, where all three are `never`: the paginator then renders the range and the rows clause only. Its `[data-grid-page-indicator]`, page numbers, first/prev/next/last and size select are ABSENT, not disabled — a control that dispatches into a void whose only visible effect is rewriting the label is worse than no control. A real client-side pagination needs a page channel on `GridClientProps`, i.e. a contract widening: **that decision belongs to a person, not to this phase.** |
|
|
98
|
+
| `resultTotal` | `number` | `undefined` | See the table above — client branch. The figure after «of». **The scope is POST-PRUNING on one condition:** it is built from `engine.state.query.filters`, which is the pruned list only if whoever lifts `filters` ALSO lifts `columnVisibility` — `case "view/column-visibility"` does not touch `query.filters`, so the grid cannot guarantee it alone. (Fase 8 follow-up: the pruning itself filters on `columnId` without consulting `kind`, so a `kind: "custom"` badge would be dropped and all three readers would agree on a wrong figure.) |
|
|
99
|
+
| `serverConfig.totalItems` | `number` | — | The server-branch twin of `resultTotal`. Also what TanStack uses for `getPageCount()`. |
|
|
100
|
+
| grid scope `scopeTotal` | derived, not a prop | — | The one figure three readers share: `resultTotal ?? serverConfig.totalItems ?? (paginated && !serverMode && !infinite.hasMore ? engine.filteredRowCount : undefined)`. **The third term is `adg-m2`** and needs no prop, because a grid that slices its own rows already knows the total. `engine.filteredRowCount` is `table.getPrePaginationRowModel().rows.length` — **the same model `getPageCount()` divides**, so `scope.pageCount` and `engine.pageCount` cannot separate. **It does not switch on where the scope is unknown:** under `serverMode` `data` is the server's slice (it would say 10 of 128), and while `infinite.hasMore` the total does not exist yet — the same term that makes `aria-rowcount` `-1`. |
|
|
101
|
+
| `GridBadgeFilter.kind` | `"column" \| "custom"` | `"column"` when absent | **Declared in Fase 4, and READ since Fase 8.** It is the discriminant of the badge: absent means `"column"`. `"custom"` marks a filter the application declared through `GridFilterControl` — it cites no column, the pruning never drops it (`isBadgeActive` reads `kind` first), the compiler ships its `customFragment` instead of rebuilding a condition from `columnAccessorKey`, and the four text surfaces (chip, panel row, dashed toolbar group, status line) read `customLabel` instead of composing «column + operator + value». Three companion fields travel with it: `customKey` (the registry key), `customLabel` (the text `describe` returned) and `customFragment` (the payload piece `toFragment` returned). |
|
|
102
|
+
|
|
103
|
+
**The scope is on the PUBLIC surface**, and it has to be: `GridFilterChips` is a public export and its new `scope` prop is typed `GridResultScope`, so a consumer mounting the chip row outside laif-ds had no exported way to BUILD that scope nor to NAME the type — the `[data-grid-filter-scope]` clause was dead code outside this repo. Exported from `laif-ds`: `gridResultScope`, `GRID_PAGE_SIZE_OPTIONS`, `GRID_IT_LOCALE`, and the types `GridResultScope`, `GridResultScopeInput`, `GridPageSizeOption`, `GridPaginationSlots`, `GridBadgeFilterKind`. `GridPageSizeOption` matters in practice: `pagination.pageSizeOptions` is `readonly GridPageSizeOption[]`, and an array declared in a variable without `as const` is `number[]`, so tsc names a type the consumer must be able to import.
|
|
104
|
+
|
|
105
|
+
**`aria-rowcount` is the declared total, not the rendered rows.** Per ARIA it is the count of ALL rows (`-1` when unknown), so on a paginated grid counting the RENDERED rows announced «row 1 of 10» next to a visible «Rows 1-10 of 128» — a false total, which is the very argument written next to the `-1` used for an infinite source. It is now `resultTotal ?? serverConfig.totalItems ?? (derived when the grid paginates itself) ?? rendered rows`, and `-1` while an infinite source still has pages. See «The scope on a self-paginating grid» for the derived term.
|
|
106
|
+
|
|
107
|
+
### The scope on a self-paginating grid
|
|
108
|
+
|
|
109
|
+
**A grid that slices its own rows does not have to be told how many there are.** With `paginated` on
|
|
110
|
+
and no server behind it, `data` is the WHOLE corpus and the grid does the cutting, so the total is
|
|
111
|
+
already in the engine. `scopeTotal` therefore carries a third term after the two declared ones:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
resultTotal ??
|
|
115
|
+
serverConfig?.totalItems ??
|
|
116
|
+
(paginated && !serverMode && !(infinite !== undefined && infinite.hasMore)
|
|
117
|
+
? engine.filteredRowCount
|
|
118
|
+
: undefined);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Why it is not a prop.** `resultTotal` lives only on `GridClientProps`, the branch that declares
|
|
122
|
+
`paginated?: never` (`app-data-grid.types.ts:1081`, cited by line number from a gate), and
|
|
123
|
+
`GridServerProps` does not carry it at all. `<AppDataGrid {...props} paginated resultTotal={128} />`
|
|
124
|
+
is `error TS2322`, measured with `tsc --noEmit` on the real file. Deriving the figure closes the gap
|
|
125
|
+
without adding a byte to the frozen contract.
|
|
126
|
+
|
|
127
|
+
**`filteredRowCount` is the model the paginator itself divides.** It is
|
|
128
|
+
`table.getPrePaginationRowModel().rows.length` — not `getFilteredRowModel()`, not the row model, not
|
|
129
|
+
the rendered rows. `engine.pageCount` is `Math.ceil` of that same number over the page size, so
|
|
130
|
+
`scope.pageCount` and `engine.pageCount` cannot separate: two derivations of one figure inside one
|
|
131
|
+
control is the defect the single-source rule exists to forbid.
|
|
132
|
+
|
|
133
|
+
**The declared limit.** On a `data` the CALLER has already sliced, `filteredRowCount` describes THE
|
|
134
|
+
SLICE and not the total — which is why the derivation is switched off under `serverMode`, where the
|
|
135
|
+
grid does not re-filter (`manualFiltering`) and the page arrives from outside. There the scope is
|
|
136
|
+
`serverConfig.totalItems`, and only that.
|
|
137
|
+
|
|
138
|
+
**And it stays unknown while the source is still open.** The third term carries the same
|
|
139
|
+
`infinite.hasMore` clause that makes `aria-rowcount` `-1`: with pages still to come, what the grid has
|
|
140
|
+
received is not a total, and announcing it as one is worse than announcing nothing. Both surfaces —
|
|
141
|
+
the visible figure and the attribute — go quiet together, which is what
|
|
142
|
+
`app-data-grid-infinite.stories.tsx :: InfiniteConPaginatedNonChiedeNulla` keeps green.
|
|
143
|
+
|
|
144
|
+
**What it closed, measured.** On `AppDataGrid/Completa :: ModalitaFiltri` (128 rows, 10 per page)
|
|
145
|
+
`[data-grid-page-range]` did not exist and `aria-rowcount` was `10`; it now reads «Righe 1-10 di 128»
|
|
146
|
+
and `aria-rowcount="128"`, with ten rows still rendered.
|
|
147
|
+
|
|
148
|
+
The selector dispatches `query/page-size` through the engine's `applyAndEmit` — ONE emission per gesture. `applyAndEmit` and not `applyView` is decided by the type (`query/page-size` is a member of `GridQueryAction`) and by the meaning: `querySignature` includes `pageSize`, so a new size is a new list the server has to know about. The reducer's `case` already restarts on `pageIndex: 0`, so no extra `query/page` is dispatched.
|
|
149
|
+
|
|
150
|
+
### What arrives in `onQueryChange`
|
|
151
|
+
|
|
152
|
+
`GridServerState` is field-for-field the old `ServerState`, `computedFilter` included — that field is built from the search term across the searchable columns using the same algorithm as `DataTable` (one column → the bare condition, several → an `_or`), so a migrated handler that forwards it keeps filtering server-side. **Searchable is OPT-IN, as in `DataTable`**: only the columns that declare `searchable: true` are compiled into it, so a term is never compiled against a date, a boolean or an id array nobody asked to search — see the `searchable` row of the accessor-builder table for the default that changed on 2026-09-10 and the two backend answers that changed it. The term itself always travels in `filters.searchbarFilter`, searchable columns or not, so a handler that reads THAT field never sees a difference. Column-filter badges **are** populated: `filters.filterBadges` carries the active badges — the lifted `filters` prop, or `initialState.filters.filterBadges` at mount — into every payload, through `toGridServerState` (`grid-reducer.ts:576`). `advancedFilterBadge` is in the type and still **not** populated: nothing in the lane writes it, and the emitter story pins it to `undefined` while `filterBadges` carries the fixture.
|
|
153
|
+
|
|
154
|
+
Column-filter badges compile through the **same algorithm** as the frozen table, quirks included (valueless operators emit `value: true`; `list_single_select` values are array-wrapped; the empty string is dropped for simple badges but not for advanced conditions). A story asserts the two payloads are byte-identical across all twenty operators — see `data-wire-equal` below. ⚠️ **From 2026-09-10 that criterion also compares the GLOBAL-SEARCH fan-out**, which the old harness never crossed: it built its two payloads with an EMPTY term, so the branch that compiles the searchbar was dead on both sides and the comparison was green while the two sides disagreed. `app-data-grid-emitter.stories.tsx :: RicercaGlobaleUgualeAllaTabellaCongelata` builds the same six-column mixed fixture (string, number, date, boolean, two multi-selects) TWICE from ONE set of configs — once with the grid builders, once with the frozen ones — derives the searchable list on each side the way the live code does (`c.searchable` against `c.meta?.searchable`) and reads the field names back OUT of the payload. Measured: with the opt-in default both sides put `name,score,tags` on the wire and the payloads are identical; with the old `?? true` the grid side put `name,score,tags,created_at,flg_valid,roles_ids` and the comparison was `false`.
|
|
155
|
+
|
|
156
|
+
Unpaginated (the default), the emitted `pagination` is `{pageIndex: 0, pageSize}` and every row is rendered client-side; with `paginated`, page changes are query actions and emit exactly once each.
|
|
157
|
+
|
|
158
|
+
### Why `onQueryChange` cannot fire on a view change
|
|
159
|
+
|
|
160
|
+
State is split into two slices held by **two separate reducers** with disjoint action types: `query` (what the server needs) and `view` (what the user configured — density, widths, visibility, order, fullscreen). Each has its own dispatcher, and only `applyAndEmit` calls `onQueryChange`. "Changing density must not cause a refetch" is therefore not a review note — it is unrepresentable. The emitter is also called **from the action, never from a `useEffect`**, which is what prevents the double initial load.
|
|
161
|
+
|
|
162
|
+
⚠️ **Since 2026-09-09 the two continuous actions are COALESCED, and this paragraph used to say the emitter was called *synchronously*.** `query/search` and `query/filters` now wait `serverConfig.serverDebounceTime ?? 200` ms and emit **once**, with the final payload; every other query action — sort, page, page size, load-more, preset — still emits immediately, and **cancels a pending coalescence** rather than queueing behind it, so one gesture never puts two requests in flight. The reason is measured: the emission used to be synchronous inside the action, so a six-letter word in the searchbar was six `onQueryChange` calls, i.e. six HTTP requests, and a value typed in the filter **row** was one per keystroke. (The filter *panel*'s range editor never had the problem: «Apply» is the only gesture that calls `onApply`.)
|
|
163
|
+
|
|
164
|
+
⚠️ **And the second pass, the same day, made the FILTERING wait too — this paragraph used to say "only the emission is deferred, never the state".** That was the whole defect that remained: the request went out once, but the table still re-filtered on every keystroke, and in **client** mode — where there is no request — the delay bought nothing at all. Measured on a client grid: typing in a filter cell took the rows from 10 to 0 within 60ms; on the searchbar, six keystrokes were six re-filterings.
|
|
165
|
+
|
|
166
|
+
**Two slices, and which reader gets which.** The **live** slice is what the *inputs* show — the searchbar is controlled by `state.query.search` and the filter-row editors read `state.query.filters`, so deferring that would make typing laggy, which is not what the delay is for. The **committed** slice is what *filters the rows* and what gets *emitted*; it advances immediately for discrete actions and after `debounceTime` for continuous ones. Committing and emitting are **one event**, so the table starts filtering with the same query snapshot the consumer receives. TanStack gets the whole committed snapshot — sorting, global filter and pagination together — because `query/search` and `query/filters` reset `pageIndex`, and mixing a live pagination with a committed filter would show page 0 of the *unfiltered* set for one frame. Measured after the change: six keystrokes leave the row count at a single distinct value, and the rows move once.
|
|
167
|
+
|
|
168
|
+
**The knob works in both modes**: `debounceTime` on the base props (`0` disables), with `serverConfig.serverDebounceTime` as the server-branch name it inherits from the frozen twin — `debounceTime` wins when both are present. The delay is no longer a property of the emitter: a client grid has no `onQueryChange` and still has something to defer, namely its own filtering.
|
|
169
|
+
|
|
170
|
+
⚠️ **Lifted filters are the consumer's, and the grid does not defer state it does not own.** With `useGridFilters`, the rows are reduced by the consumer. The hook therefore publishes **`committedBadges`** next to `badges`: pass `badges` to the grid (the editors must stay live) and filter with `committedBadges`. It is **additive on purpose** — a consumer that keeps filtering with `badges` is not broken, it simply does not get the deferral, which is the failure mode we chose: making `badges` itself the committed value would have given everyone the deferral for free and made *typing lag in production* for anyone who forgot to feed the editors the draft.
|
|
171
|
+
|
|
172
|
+
⚠️ **Inside the hook the split is by GESTURE, not by mutator name, and the first attempt got it wrong.** `updateBadge` — a value being typed or dragged — is continuous. `addBadge` is **discrete**: it is also how a badge is born from a switch, an `AppSelect` or a chip, and deferring those felt like a slow table. Measured: with `addBadge` classified as continuous, the switch in `FiltriSoloFuori` no longer announced its filter within the frame. The declared price is that a **new text filter** confirms on its first character and coalesces the rest — two confirmations per word instead of one; the global search, which does not go through the hook, stays at one.
|
|
173
|
+
|
|
174
|
+
⚠️ **Two limits, declared.** Coalescing reduces requests, it does not **sequence** them: the landing order of the responses is still the consumer's problem. And it covers the **grid's** channel only — with filters **lifted**, `onFiltersChange` is the consumer's own callback, called synchronously inside the action (see the lifted-filters section), so that path stays one call per keystroke.
|
|
175
|
+
|
|
176
|
+
⚠️ **Not the same behaviour as the frozen twin, despite the identical prop name**: in `DataTable`, `serverDebounceTime` (default 750) only delays the appearance of the loading overlay and coalesces nothing.
|
|
177
|
+
|
|
178
|
+
**The grid counts its own emissions.** The root carries `data-emissions`, and it is the contract in observable form: `0` at mount (even under `StrictMode`), `+1` per sort, `+1` per page change — and **unchanged** by any density switch. Read it from the DOM rather than instrumenting the callback. ⚠️ **It counts emissions, not gestures**, which since the coalescing above is a visible difference: a burst of keystrokes in the searchbar or the filter row raises it by **one**, not by one per key, and it rises when the window closes rather than on the keystroke — so read it inside a `waitFor`, not right after typing.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
### Cosa i tipi rifiutano (what the types reject)
|
|
183
|
+
|
|
184
|
+
`AppDataGridProps` is a **union of two variants** — a client grid and a server grid — and what
|
|
185
|
+
follows is not a list of props to pass: it is the list of shapes that **do not compile**. They are
|
|
186
|
+
documented here and deliberately kept **out of the prop table**, because a table row teaches you to
|
|
187
|
+
pass the prop, and passing these is precisely what the union forbids. The compiler is the
|
|
188
|
+
enforcement; this section is only its transcript.
|
|
189
|
+
|
|
190
|
+
| Union constraint | Declared on | What it rejects, and why |
|
|
191
|
+
| ----------------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
192
|
+
| `serverMode?: never` | client variant (`GridClientProps`) | A grouped, client-side grid does not talk to a server. `serverMode` — the name and semantics of `DataTable`, kept for the codemod — exists **only** on the server variant. Together with `groupBy` it is a compile error, not a runtime surprise. |
|
|
193
|
+
| `serverConfig?: never` | client variant (`GridClientProps`) | Same reason: `GridServerConfig` (`totalItems`, `onStateChange`, `serverDebounceTime?`) belongs to the server variant only. `onStateChange` there is an **alias** of `onQueryChange`, never a second channel — with both present `onQueryChange` wins, because two emitters would be two refetches per gesture. |
|
|
194
|
+
| `initialState?: never` | client variant (`GridClientProps`) | The server-mode trio is one trio: `initialState` travels with `serverMode` and `serverConfig`. It is documented as a prop in the table above, where it belongs — on the server variant. |
|
|
195
|
+
| `onQueryChange?: never` | client variant (`GridClientProps`) | A grouped grid does not emit: grouping is client-side by construction, so there is nothing to ask a server for. |
|
|
196
|
+
| `paginated?: never` | client variant (`GridClientProps`) | Pagination slices the rows _before_ grouping sees them, so a group would count — and `aggregatedCell` would sum — only the current page: measured at 6 rows where the group had 340, with another "Italy" reappearing on the next page. That is the number that looks like a total and is not, which is what the union exists to forbid. |
|
|
197
|
+
| `groupBy?: never` | server variant (`GridServerProps`) | Server-side grouping is not representable, and this is where the sentence stops being a promise in a document: a server grid groups nothing. |
|
|
198
|
+
|
|
199
|
+
Passing a rejected combination is caught at compile time. There is no runtime warning for it, and
|
|
200
|
+
that is the point — see `type-probes/` for the probes that assert `tsc` keeps refusing them.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Column builders
|
|
205
|
+
|
|
206
|
+
Four builders ship in Fase 1. They cover both families of the frozen `cell:` contract. **The remaining types have arrived**: `createGridBooleanColumn`, `createGridDateColumn`, `createGridDateTimeColumn`, `createGridSingleSelectColumn`, `createGridMultiSelectColumn` and `createGridComputedColumn` (P10), and since P7 the type of a column decides the filter **control** as well as the operator list — see `filterType` in the table above.
|
|
207
|
+
|
|
208
|
+
| Builder | `cell` arity | `cell` required? | `id` required? |
|
|
209
|
+
| ------------------------- | ------------------------ | ---------------- | -------------- |
|
|
210
|
+
| `createGridStringColumn` | `(value, row, tableRow)` | no | no |
|
|
211
|
+
| `createGridNumberColumn` | `(value, row, tableRow)` | no | no |
|
|
212
|
+
| `createGridActionColumn` | `(row)` | **yes** | **yes** |
|
|
213
|
+
| `createGridDisplayColumn` | `(row)` | **yes** | no |
|
|
214
|
+
| `createGridRowMenuColumn` | — (no `cell`) | n/a | **yes** |
|
|
215
|
+
|
|
216
|
+
`createGridRowMenuColumn` (Fase 2, fetta E) is the odd one out: it has **no `cell`**. The caller
|
|
217
|
+
declares the menu **items** through `rowMenu`, and the grid renders the trigger and the panel — see
|
|
218
|
+
[Row header and row menu](#row-header-and-row-menu-fase-2-fetta-e).
|
|
219
|
+
|
|
220
|
+
**Memoize `columns`.** It is compared by identity; an array rebuilt inline on every render rebuilds the column registry and the internal column defs each time. Define it at module scope or wrap it in `useMemo`, as TanStack requires of its own `columns`.
|
|
221
|
+
|
|
222
|
+
The arity difference is **deliberately copied** from `DataTable` — it is what lets an existing `cell:` closure keep compiling after a rename.
|
|
223
|
+
|
|
224
|
+
### Accessor builder options
|
|
225
|
+
|
|
226
|
+
| Option | Type | Default | Description |
|
|
227
|
+
| ----------------- | ---------------------------------------------------------------------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
228
|
+
| `accessorKey` | `string` | — | **Required.** Dotted paths (`user.name`) are read as depth. |
|
|
229
|
+
| `header` | `string \| React.ReactNode` | — | **Required.** If not a string, `headerLabel` becomes required. |
|
|
230
|
+
| `headerLabel` | `string` | `header` when it is a NON-EMPTY string, otherwise the column `id` | The text form, used by assistive tech and menus. ⚠️ **The default changed on 2026-09-09** and the reason is measurable: an EMPTY `aria-label` is skipped by accname, so with `header: ""` and no `headerLabel` the `columnheader` name recomposed itself from the `<th>` contents — **the resize handle's pixel width included**, i.e. the column's accessible name was a number. Falling back to the `id` keeps it non-empty. Applies to the six accessor builders plus action, display, row-menu and group. |
|
|
231
|
+
| `id` | `string` | `accessorKey` | Registry key. Must be stable across reloads. |
|
|
232
|
+
| `sortable` | `boolean` | `true` | ⚠️ **Still `true`, and it is the code and not an oversight**: the frozen twin defaults it to `false`, so a migrated accessor column gets a sort `<button>` (and the «Sort: …» accessible name, and the query action its click dispatches) where `DataTable` gave none. Aligning it is an owner-level decision, not a doc fix: it would move a visible control in every migrated table and would need its own story pass. Same for `filterable` below. `searchable` was NOT in that class — it changes the payload. |
|
|
233
|
+
| `filterable` | `boolean` | `true` | ⚠️ **Still `true` against the frozen twin's `false`**, deliberately, like `sortable`: the consequence is that every accessor column is OFFERED in the filter inventory and in the zero row (`use-grid-filters.ts` copies the flag, `parts/grid-filter-panel.tsx` reads it), where `DataTable` listed only what the call-site declared. Nothing compiles it into `computedFilter` by default, so the wire is unaffected. |
|
|
234
|
+
| `searchable` | `boolean` | `false` | Whether the global search looks at this column. ⚠️ **OPT-IN, and the default CHANGED on 2026-09-10: it used to be `true`.** It is `false` because `buildColumnBase` in the frozen `DataTable` (`data-table.utils.ts`) is `false`, and because this flag decides WHAT REACHES THE WIRE: `searchableColumns` (`engine/use-grid-engine.ts`) takes every column carrying it and `buildComputedFilter` (`grid-reducer.ts`) compiles one branch of an `_or` for each. With the old default a term was compiled against dates, booleans and id arrays too, and it is not an aesthetic detail — measured against the template's real backend: `/users/search` with «admin» answered **404**, because `roles_ids` was compiled as `array_overlap` with `["admin"]` and `integer[] && text[]` is not a Postgres operator (`CAST(dat_connection AS VARCHAR) ILIKE '%admin%'` travelled in the same payload); `/permissions/search` with «2» answered 200 with **27 rows out of 33**, matched through `id LIKE '%2%'` and `roles_count LIKE '%2%'`. Declare `searchable: true` on the columns the search must look at — the same thing a `DataTable` call-site already writes, which is why `data-wire-equal` now compares the search fan-out too. ⚠️ **In client mode the same flag gates the LOCAL filter** (`getColumnCanGlobalFilter` reads `registry.get(id)?.searchable ?? false`), so a grid whose columns declare nothing does not filter on typing — the frozen table's behaviour as well. |
|
|
235
|
+
| `pinned` | `"left" \| "right"` | `undefined` | Freezes the column against one edge while the grid scrolls horizontally. `"left"` since P4, `"right"` since Fase 2. Offsets accumulate over the pinned columns of that side, from that edge: on the right the **last** pinned column sits at `0` and each earlier one steps back by the widths to its right. |
|
|
236
|
+
| `align` | `"left" \| "right"` | `"left"` | Which edge the column's content aligns to. `"right"` also brings `tabular-nums`, because right-aligning a numeric column without fixed-width digits does not line the units up — which is the only reason to right-align numbers. It applies to the leaf `<th>`, the data `<td>` **and** the group-row cell: a column aligned in its cells but not in its own header reads as two overlapping columns. |
|
|
237
|
+
| `rowHeader` | `boolean` | `false` | Renders this column's cell as `<th scope="row">` instead of `<td>` — the word with which HTML says "this cell heads the others in its row". **At most one column per grid may declare it**, and a second one throws: two row headers make the row announce itself with two different names. It is an explicit opt-in, never derived from "the first column" — the first column may be the one that _groups_ (its cell is then muted, so the row header would have no name) or hidden by `columnVisibility`. |
|
|
238
|
+
| `rowMenu` | `(row: TData) => GridRowMenuItem[] \| undefined` | `undefined` | Only on `createGridRowMenuColumn`, where it is **required**. The cell renders the `…` trigger instead of its own content. Returning an empty list renders no trigger. See [Row header and row menu](#row-header-and-row-menu-fase-2-fetta-e). |
|
|
239
|
+
| `width` | `number` | `160` | Initial width in px. A drag replaces it; from then on this number no longer participates. |
|
|
240
|
+
| `cell` | `(value, row, tableRow) => ReactNode` | `undefined` | Falls back to the stringified value. |
|
|
241
|
+
| `cellClassName` | `string \| ((value, row) => string)` | `undefined` | Note the **two**-parameter callback — a third asymmetry, copied. |
|
|
242
|
+
| `headerClassName` | `string` | `undefined` | — |
|
|
243
|
+
| `aggregatedCell` | `(rows: TData[]) => ReactNode` | `undefined` | The aggregate rendered in the group row (P9). It receives the **original rows** descending from the group at any depth — not the values of its own column — so it can write "three of twelve above the threshold". **Absent = an empty group cell**: this is the only way to fill one. |
|
|
244
|
+
| `aggregate` | `(rows: TData[]) => ReactNode` | `undefined` | **Fase 5.** The column total rendered in the `<tfoot>`. **It sums the RENDERED rows, not the group aggregates** — with two `groupBy` levels every row belongs to two group nodes (`buildGridGroupTree` accumulates it at each level traversed), so summing the group totals counts each row once per level: 2× with two levels. «Rendered» is the **row model** — after search, filters and sorting, and with `paginated` the current page slice — **not** the rows visible in the DOM: a collapsed group hides its leaves and does not remove them from the total. Absent = an empty footer cell, and if NO visible column declares one the `<tfoot>` does not exist at all. Only on accessor builders: an action or display column carries no per-row value to aggregate. |
|
|
245
|
+
| `options` | `readonly string[] \| readonly GridSelectOption[] \| readonly AppSelectOption[]` | `undefined` | The options of a list column (P10), for `createGridSingleSelectColumn` / `createGridMultiSelectColumn`. Bare strings are normalised by the builder into `{value, label}`, so downstream there is **one** shape only. |
|
|
246
|
+
| `filterType` | `"string" \| "number" \| "boolean" \| "date" \| "datetime" \| "list_single_select" \| "list_multi_select"` | `columnType` | **P7. How the column is FILTERED, when its `columnType` does not say it.** `columnType` decides how the value is RENDERED; `filterType` decides which operators the panel offers and which control writes the value — a `string` column that declares `filterType: "list_single_select"` plus `options` is filtered from a `<select>` over those options instead of a free text field, which is the whole point of the phase. Absent falls back to `columnType`, so every column written before P7 keeps filtering exactly as it did. The union is `Exclude<GridColumnType, "other">`: a computed column (`columnType: "other"`) has **no** filter vocabulary, so `gridColumnFilterControl` returns `undefined` and the panel does not offer it at all — which is how «no filterType is left without operators» holds by construction rather than by a fallback. It is a **per-column** option, so `check-grid-props-documented.mjs` does not require it (that gate reads `AppDataGridProps`); it is documented here because for an agent writing a call-site a prop that is not in this table does not exist. |
|
|
247
|
+
|
|
248
|
+
### Column group builder (P5)
|
|
249
|
+
|
|
250
|
+
`createGridColumnGroup({ id, header, headerLabel?, columns })` produces a header that **covers** other
|
|
251
|
+
columns. A group is not a column: it has no `accessorKey`, no `cell`, and is never sortable, filterable or
|
|
252
|
+
resizable.
|
|
253
|
+
|
|
254
|
+
| Option | Type | Default | Description |
|
|
255
|
+
| ------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
256
|
+
| `id` | `string` | — | **Required, and never derived from the label.** It is the root of the DOM ids in the a11y chain: two groups both called "Anagrafica" in different branches would produce the same chain — body cells pointing at the wrong header, with `tsc` green. It must also be unique against the **leaf** column ids, not only against the other groups: `validateGridColumnTree` throws «due nodi condividono l'id "…"» when a group reuses the id of a column it covers, so a group over `avanzamento` is named `progresso`, never `avanzamento`. |
|
|
257
|
+
| `header` | `string \| ReactNode` | — | **Required.** |
|
|
258
|
+
| `headerLabel` | `string` | `header` | Required when `header` is not a string — it is the text an assistive technology reads. |
|
|
259
|
+
| `columns` | `GridColumnNode<TData>[]` | — | **Required.** Leaf columns or further groups. Nesting has no ceiling. |
|
|
260
|
+
|
|
261
|
+
Groups reach nothing below the `<thead>`: the engine **flattens** them into leaves before the registry sees
|
|
262
|
+
them, so rows, sorting, filtering, widths and pinning keep seeing the flat list of P1-P4.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Usage
|
|
267
|
+
|
|
268
|
+
```tsx
|
|
269
|
+
import {
|
|
270
|
+
AppDataGrid,
|
|
271
|
+
createGridActionColumn,
|
|
272
|
+
createGridNumberColumn,
|
|
273
|
+
createGridStringColumn,
|
|
274
|
+
type GridColumnDef,
|
|
275
|
+
} from "laif-ds";
|
|
276
|
+
|
|
277
|
+
interface Invoice {
|
|
278
|
+
number: string;
|
|
279
|
+
customer: string;
|
|
280
|
+
total: number;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const columns: GridColumnDef<Invoice>[] = [
|
|
284
|
+
createGridStringColumn<Invoice>({
|
|
285
|
+
accessorKey: "number",
|
|
286
|
+
header: "Number",
|
|
287
|
+
}),
|
|
288
|
+
createGridStringColumn<Invoice>({
|
|
289
|
+
accessorKey: "customer",
|
|
290
|
+
header: "Customer",
|
|
291
|
+
}),
|
|
292
|
+
createGridNumberColumn<Invoice, number>({
|
|
293
|
+
accessorKey: "total",
|
|
294
|
+
header: "Total",
|
|
295
|
+
cell: (value) => `€ ${value.toFixed(2)}`,
|
|
296
|
+
}),
|
|
297
|
+
createGridActionColumn<Invoice>({
|
|
298
|
+
id: "actions",
|
|
299
|
+
header: "Actions",
|
|
300
|
+
cell: (row) => <Button onClick={() => open(row.number)}>Open</Button>,
|
|
301
|
+
}),
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
export const InvoicesGrid = ({ invoices }: { invoices: Invoice[] }) => (
|
|
305
|
+
<AppDataGrid
|
|
306
|
+
columns={columns}
|
|
307
|
+
data={invoices}
|
|
308
|
+
onQueryChange={(state) => {
|
|
309
|
+
// Fires on search / sort / filter / page — never on a view change.
|
|
310
|
+
void fetchInvoices(state);
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
);
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## Coexistence with `DataTable`
|
|
319
|
+
|
|
320
|
+
Both can be mounted on the same page. What makes that safe is enforced mechanically by six CI gates, not by convention:
|
|
321
|
+
|
|
322
|
+
| Guarantee | Enforced by |
|
|
323
|
+
| ----------------------------------------------------- | ------------------------------------- |
|
|
324
|
+
| No import from `tables/data-table/`, barrels included | `check-grid-isolation.mjs` |
|
|
325
|
+
| No `ColumnMeta` module augmentation, no `meta:` | `check-grid-isolation.mjs` |
|
|
326
|
+
| No `export *` (a collision would be TS2308) | `check-grid-isolation.mjs` |
|
|
327
|
+
| `@tanstack/*` confined to one file | `check-grid-isolation.mjs` |
|
|
328
|
+
| Copied contracts cannot drift from the originals | `check-datatable-grid-parity.mjs` |
|
|
329
|
+
| Grid and a shared primitive never in one change | `check-datatable-shared-surface.mjs` |
|
|
330
|
+
| `designTokens` is append-only | `check-design-tokens-append-only.mjs` |
|
|
331
|
+
| No arbitrary Tailwind classes; all resolve in v3 | `check-grid-css.mjs` |
|
|
332
|
+
| Hand-written lane rules identical in the THREE CSS sheets | `check-grid-css.mjs` |
|
|
333
|
+
| The same rules identical in the two BUILT bundles | `check-grid-css.mjs` |
|
|
334
|
+
|
|
335
|
+
**The search field's native «×» is suppressed by a hand-written rule that has to live in THREE stylesheets.** `[data-grid-search]` keeps `type="search"` — that is what gives it the implicit `searchbox` role and the platform's Escape-to-clear, and a story asserts the attribute — so Blink paints its own `::-webkit-search-cancel-button` next to the grid's `[data-grid-search-clear]`. The suppression cannot be a Tailwind class (it would need the arbitrary variant `&::-webkit-search-cancel-button`, which rule 1 of `check-grid-css.mjs` refuses in the lane), so it is written by hand as `[data-app-data-grid] input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none }` — ONE declaration, `-webkit-` prefixed, because the unprefixed form is dead by construction: the selector does not exist outside WebKit/Blink. It must be in `src/styles/index.css`, `src/styles/index.v3.css` **and** `src/styles/css-for-template.css`, because a consumer receives `dist/styles.css`, or `dist/styles.v3.css`, or — if it is a template app — **only** `dist/css-for-template.css`, which `copy:template-css` produces as a verbatim `cp` of that third sheet. ⚠️ **It shipped in the first two and not the third, and the template showed TWO «×»**: measured on the live page, 6 stylesheets and 2936 rules, ZERO of them mentioning `search-cancel-button`. `check-grid-css.mjs` rule 3 now compares all three sheets (`CSS_LANE_SHEETS`), so the same omission is red — count mismatch when the rule is absent, per-rule diff when it is present but different. **Declared residual, written in the gate:** the third sheet is NOT in rule 4's freshness reference (it is not an input of `dist/styles.v3.css` at all), so editing only that sheet leaves `dist/styles.css` genuinely stale without the guard noticing — `npm run build:css` rebuilds it before publishing either way. And `index.css` `@import`s the third sheet, so the v4 bundle sees the rule twice: measured, Lightning CSS merges the identical duplicates and rule 4 compares 1 against 1. If a future toolchain stops merging, the repair is removing the copy the `@import` makes redundant, not weakening rule 4.
|
|
336
|
+
|
|
337
|
+
Grid menus use `modal={false}`, so opening one does **not** put `aria-hidden` on the old table or lock page scroll. A story mounts both and asserts exactly that.
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## DOM contract (for tests)
|
|
342
|
+
|
|
343
|
+
Stable hooks, independent of the i18n language:
|
|
344
|
+
|
|
345
|
+
| Selector | What it marks |
|
|
346
|
+
| ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
347
|
+
| `[data-app-data-grid]` | The grid container — carries `data-emissions`, `data-render-count`, `data-mount-count`, `data-loadmore-call-count` and `data-grid-fullscreen` |
|
|
348
|
+
| `[data-grid-search]` | The search input. **From Fase 3 it carries a VISIBLE `<label>`** bound by `htmlFor` (supplied by the `Input` primitive from `toolbar.searchLabel`) and **no `aria-label`** — the two could otherwise diverge, and the `aria-label` would win. **From `adg-m3` the label sits BESIDE the field, not above it**, the field carries a magnifier INSIDE it, and the cell no longer grows: four attributes on this node (`iconLeft="Search"`, `size="sm"`, `labelClassName`, and a `className` that turns the primitive's `flex-col` into `flex-row items-center`), no slot and no change to `ui/input.tsx`. ⚠️ **`[data-grid-search]` is the `<input>` itself, not the box you see**: the primitive wraps input + magnifier in a bordered box and that box in a label/field row, so a geometric probe wants `.parentElement` (the field) — reading the `<input>` measures the text area inside the padding. ⚠️ **The label's `top` does NOT equal the field's `top`** — the label is shorter and vertically CENTRED in the row, 8-9px lower; the measurable property is that the two vertical CENTRES coincide (within 2px) and the label's `right` is at or before the field's `left`. The mockup itself behaves this way, measured. ⚠️ **`flex-1` is gone, and that is load-bearing**: with the field growing, the `ml-auto` on the toolbar's right group has no residual margin to take and is dead — see `[data-grid-toolbar]`. ⚠️ **Closed with a declared residual**: the field measures **182×32** against the mockup's **208×32**. The height is exact (`size="sm"` → `h-8`); the width is not, and is deliberately out of reach — copying the mockup's `w-52` would hardwire a second truth about how wide a search field is, to be kept in agreement with the first at every touch. Without `flex-1` the cell sizes to the `<input>`'s intrinsic content, which is the same scale without the number |
|
|
349
|
+
| `[data-grid-viewport]` | The scroll container (P3 takes it over) |
|
|
350
|
+
| `[data-grid-table]` | The `<table>` |
|
|
351
|
+
| `[data-grid-col="<id>"]` | The `<col>` of a column |
|
|
352
|
+
| `[data-grid-header="<id>"]` | A **leaf** `<th>`; carries `aria-sort`, `id` and `aria-label` |
|
|
353
|
+
| `[data-grid-header-row="<n>"]` | A header `<tr>`, 0-based from the top (P5) |
|
|
354
|
+
| `[data-grid-header-group="<id>"]` | A **group** `<th>`; carries `scope="colgroup"` and its `colSpan` |
|
|
355
|
+
| `[data-grid-header-placeholder]` | An empty `<th>` standing in for a leaf in the rows above it — never a `rowSpan` |
|
|
356
|
+
| `[data-grid-sort="<id>"]` | The sort button |
|
|
357
|
+
| `[data-grid-row]` | A **data** row — never a spacer |
|
|
358
|
+
| `[data-grid-cell="<id>"]` | A cell — a `<td>`, or the `<th scope="row">` of the row-header column |
|
|
359
|
+
| `[data-grid-row-header]` | The one cell per row that heads it. Always a `<th scope="row">`, and always exactly one when a column declares `rowHeader` (Fase 2, fetta E) |
|
|
360
|
+
| `[data-grid-cell-text]` | A text cell that **fits** its column — no target, no tooltip, no tab stop added |
|
|
361
|
+
| `[data-grid-cell-expand]` | The focusable target of a text cell that **is** truncated. Its accessible name is the full value; the same value is in an `sr-only` node |
|
|
362
|
+
| `[data-grid-cell-tooltip]` | The tooltip panel of a truncated cell. Portaled to `<body>` — never inside the row |
|
|
363
|
+
| `[data-grid-row-menu]` | The `…` trigger of the row menu. Accessible name = the label plus the row's identity (from the `rowHeader` column) |
|
|
364
|
+
| `[data-grid-row-menu-item="<id>"]` | One item of the row menu. A destructive item carries `data-variant="destructive"` |
|
|
365
|
+
| `[data-grid-spacer="top\|bottom"]` | The spacer rows. Height `0` unless virtualized; inline `style.height` when it is |
|
|
366
|
+
| `[data-grid-pinned="true"]` | A cell pinned to **either** edge (header stub or body cell). The side is in the computed style: `left` for the left band, `right` for the right one |
|
|
367
|
+
| `[data-grid-resize-handle="<id>"]` | The resize handle of a column — `role="separator"`, focusable, `aria-valuenow`. The 8px band itself is **never painted at rest** (WCAG 2.5.8 target, not decoration); the visible mark is `[data-grid-resize-track]` inside it |
|
|
368
|
+
| `[data-grid-resize-track="<id>"]` | The visible **1px** mark inside the resize handle. It is **not a line that matches the group separator — it is that line, continued**: `w-px` + `translate-x-full` put it on the strip `[L, L+1]`, where `L` is the column boundary, which is exactly where `gridHeaderGroupShadow` (`inset 1px 0 0 0 var(--d-border)`) draws the rule between column groups. `bg-d-border` at rest, `d-primary` and 4px on hover. At rest the translation is `full` and not `1/2` because that is what puts the left edge exactly on the boundary at 1px. ⚠️ **On hover the translation is cancelled (`group-hover:translate-x-0`) so the 4px grow INWARDS, `[L-4, L]`:** with the translation still active they landed at `[L, L+4]`, past the boundary — and on a column whose boundary IS the table's right edge (the last one) those pixels fall outside the scrolling viewport, so the hover was never visible; the same happened at a right-pinned boundary, where the pinned cell paints its own background above it. Growing inwards is also the right reading: the handle belongs to the column being resized. Measured by `In hover la traccia cresce verso l'interno` with the browser's REAL pointer (`@vitest/browser/context`) — `storybook/test`'s `userEvent.hover` dispatches synthetic events that do not activate `:hover`, and the target's rect must be re-read after the hover because the driver scrolls it into view first (measured: 228px of stale offset). ⚠️ **Three geometries were rejected before this one**, and the measurement that settled it is that header cells carry NO vertical border at all (`borderRightWidth: 0px` on every one): flush at `right-0` the mark sat at `[L-1, L]`, inside the column where no other rule passes; centred with `translate-x-1/2` it sat half a pixel to the left of the groups' strip. Two specs guard the pair — `La maniglia si vede a riposo` (position and width) and `La traccia della maniglia continua la linea fra i gruppi` (the two rules coincide, DOM against DOM), both with a 0.1px tolerance because a 0.5px one swallowed exactly the half-pixel defect. ⚠️ **Three passes, and the last two are the decision:** at 1px this same subtle tone is invisible (**1.21:1** against the background, measured in the browser), and `bg-d-foreground/40` fixed the ratio (**2.82:1**) at the cost of leaving the family — a line darker than every other rule in the grid reads as content, not as a hint. The delivered form keeps the subtle tone (`--d-border`, the token behind the grid's other 28 border/divide usages) and buys perception with WIDTH. ⚠️ **The rest contrast is 1.21:1 and stays under the 3:1 of WCAG 1.4.11 — declared, not ignored:** at rest the mark carries no information missing elsewhere (the handle is already a named, focusable `separator` with `aria-valuenow`), and the hover state is accent at 4px. The spec therefore asserts the token identity and the 2px width, not a contrast floor this choice does not claim. `aria-hidden` and `pointer-events-none`: the separator is already named and must keep receiving the pointer. It exists because a fully transparent handle is an affordance that never announces itself |
|
|
369
|
+
| `[data-grid-col-filler]` | The layout-only extra `<col>` (P4). Never matched by `[data-grid-col]`. ⚠️ **From `adg-m3` it is much THINNER in the underflow regime, and still indispensable.** The slack is now distributed over the columns that fell back to `gridColumnWidth.default` and WRITTEN to their `<col>` (`distributeGridColumnSlack`, `grid-tokens.ts`), so this node absorbs only the `Math.floor` remainder — strictly fewer px than the number of elastic columns — plus whatever `gridColumnWidth.max` refused. It is NOT removable: without it `table-layout: fixed` inflates the declared widths whenever their sum is below the table width (measured: declared 160, rendered 225 on four columns in a 1000px box), and pinned offsets computed from the declared numbers desynchronise at the first paint. A grid that declares every width, or whose columns the user has dragged, keeps its FULL filler — those columns are never distributed to |
|
|
370
|
+
| `[data-grid-density-control]` | The density switcher `role="group"`; each button is `[data-grid-density="<v>"]`. ⚠️ **From `adg-c12` the grid never renders this** — it is `GridDensityControl`, a public export the CONSUMER mounts (in `toolbar.actions`, or outside the table). The attributes are unchanged, so a test that looked for it still finds it wherever it is now mounted; a test that asserted it was rendered by default must be inverted |
|
|
371
|
+
| `[data-grid-fullscreen-toggle]` | The full-screen toggle button (P6). Its accessible name changes with the state, and there is deliberately **no `aria-pressed`**: a toggle that both renames itself and announces «pressed» is heard twice and contradictorily. **From `adg-m3` the name is the VISIBLE text** — `innerText` is `i18n.toolbar.fullscreenEnter` / `fullscreenExit` — and the button carries **no `aria-label` and no `title`**: an `aria-label` REPLACES the accessible name, so keeping it beside visible words would reopen WCAG 2.5.3 «Label in Name», the same defect the P6 review fixed on the density buttons and Fase 3 on the search field. The icon is `aria-hidden`. Its dress is the toolbar triggers' shared `gridToolbarTriggerClass` (`h-6 px-2 text-xs`, ≥24px per WCAG 2.2 SC 2.5.8) and no longer a local `cn(...)`, so a probe can assert it by IDENTITY — a `toContain("h-6")` would stay green on a copy |
|
|
372
|
+
| `[data-grid-actions-row]` | **`adg-m3`.** The wrapper of the `actions` prop's commands — `role="toolbar"`, INSIDE `[data-grid-toolbar]`, in its `.ml-auto` right group. ⚠️ **The name says «row» and it is no longer a row**: it was one until `adg-m3`, when it moved into the bar, and it was deliberately NOT renamed — three story files resolve this selector and none of them asserts a POSITION, so renaming a layout change into a hook migration would have been the more expensive mistake. Absent when `actions` is empty or `hideActionsRow` is on |
|
|
373
|
+
| `[data-grid-action="<label>"]` | **`adg-m3`.** One command. ⚠️ **In the DOM only where it is REACHABLE:** with ONE action it is the inline button in the bar; with TWO OR MORE it lives inside `[data-grid-actions-panel]` and therefore exists only while the popover is OPEN. A test that counts these must click the trigger first — before `adg-m3` all of them were in the DOM at rest |
|
|
374
|
+
| `[data-grid-actions-trigger]` | **`adg-m3`.** The single collapsed trigger, rendered INSTEAD of the buttons from two actions up. Named `i18n.tableActions.actions`, dressed with `gridToolbarTriggerClass`, `iconRight="ChevronDown"`. It re-declares `data-slot="button"`, because with `asChild` Radix's `data-slot="popover-trigger"` would win otherwise — same reason as the columns trigger |
|
|
375
|
+
| `[data-grid-actions-panel]` | **`adg-m3`.** The popover holding the collapsed commands: `align="end"`, `modal={false}`, and portalled into the grid ROOT — same `container` + `rootRef` pattern as `[data-grid-toolbar-columns-panel]`, for the same measured reason (at full screen the root creates a stacking context and a body-portalled panel paints underneath) |
|
|
376
|
+
| `[data-grid-skeleton-row]` | A skeleton row (P8) — `aria-hidden`, no `aria-rowindex`, exactly the height of a data row. Their count is the LIVE page size **capped at `1_000`** (`Math.min`, see the `pageSize` row: the cap is what keeps `Array.from({length})` from raising `RangeError` or exhausting the heap), so outside `loading` it is 0 by construction and «zero skeletons» is a vacuous assertion there (limit declared since Fase 4) |
|
|
377
|
+
| `[data-grid-live-region]` | The `role="status" aria-live="polite"` region announcing that a page is loading / has loaded (P8). **From Fase 5 it announces all three outcomes and stays the ONLY one in the grid** — exactly one `[role="status"]` per grid, in the empty, error and loading states alike. The branch order is loading → error → empty, and the error branch comes FIRST of the two because a failed fetch also has zero rows. What it announces is always a STRING: `i18n.notFoundMessage` for the empty outcome (**never** `emptyComponent`) and `i18n.table.errorMessage` for the error one (never the surface, which contains a button a screen reader would read twice). **From M4 it can also be READ on screen**: with `statusRowVisible` it is wrapped in `[data-grid-live-region-frame]` and loses `sr-only`, and nothing else about it changes — same node, same attributes, same single text node, same place between the scroll area and the tail sentinel |
|
|
378
|
+
| `[data-grid-live-region-frame]` | **M4.** The frame that makes the status line readable, rendered ONLY with `statusRowVisible`. Holds the megaphone `<svg>` (`aria-hidden`) plus the live region, in that order. **It carries no `role` and no `aria-live` on purpose**: a role here would take the count of live regions per grid to two, and the icon inside the region would have the sentence announced in pieces. Absent by default, and then the region is again a direct child of the root with `sr-only` |
|
|
379
|
+
| `[data-grid-group-row="<path>"]` | A group row (P9) — carries `aria-expanded` and `data-grid-group-level` |
|
|
380
|
+
| `[data-grid-group-toggle="<path>"]` | Its expand/collapse control. The accessible name changes with the state |
|
|
381
|
+
| `[data-grid-group-cell="<id>"]` | A cell of a group row — one per visible leaf column, never one spanning many |
|
|
382
|
+
| `[data-grid-group-label]` | The grouping value. It lands in the **grouping column's** cell by default, or in the **first rendered column** with `groupLabelPlacement="first-column"` (M5) — the chevron and the count travel with it, always inside the same cell |
|
|
383
|
+
| `[data-grid-group-count]` | The number of data rows under the group, at any depth. ⚠️ **Read the ATTRIBUTE, not the text:** the attribute is the bare numeral (`78`), while the text reads `(78)` — the parentheses are literal in the node, wrapping the numeral only, as in the mockup. The word (`grouping.rowCount`: «rows» / «righe») follows inside an `sr-only` span, so the accessible name is the SUM of the visible number and the word. **No `aria-label` on this node**, deliberately: it would replace the visible number instead of extending it (WCAG 2.5.3 Label in Name) |
|
|
384
|
+
| `tr[data-grid-filter-row]` | **The ZERO ROW** — the FOURTH `<tr>` of `<thead>`, rendered only with `filterMode="row"`, and in that mode only while the toggle is OPEN. **Not rendered at all while the toggle is closed** — since R13 hiding the row unmounts it, so «the row is hidden» is measured as the ABSENCE of this node and there is no state attribute left to read. ⚠️ **Match it as `tr[data-grid-filter-row]`, never bare**: a badge row inside the inventory panel is a `<div>` carrying `data-grid-filter-row="<columnId>"`, and with both surfaces mounted a bare selector takes whichever comes first in the DOM |
|
|
385
|
+
| `[data-grid-filter-cell="<id>"]` | A cell of the zero row — one per leaf column plus `"__select"` when `checkable`, in the order of the columns, **never** with `colSpan`. Carries `data-grid-filter-cell-label` (the column's name) and `data-grid-filter-cell-applied="true"\|"false"`. Until `adg-r14` these were also the nodes the «N filters out of view» count was measured on; that indicator has been REMOVED from the toolbar and nothing reads their rects any more |
|
|
386
|
+
| `[data-grid-filter-cell-tick="<id>"]` | The zero row's **column mark**: a 1px-wide, `h-3` (12px) span pinned to the cell's LEFT edge and vertically CENTRED, `aria-hidden` and `pointer-events-none`. Present on every filter cell except the first and except a right-pinned block's edge cell. ⚠️ **It is a node and not a `box-shadow`:** an `inset` shadow covers the box's whole side, so the mark came out as tall as the cell and welded the zero row to the headers. Its height is asserted, not just its presence, because a full-height mark would satisfy a probe that only counts nodes |
|
|
387
|
+
| `[data-grid-filter-cell-trigger="<id>"]` | The one target of a filter cell: a real `<button>`, the cell's only tab stop, opening the Fase 7 filter atom in a popover. Carries `data-filter-truncated="true"\|"false"` — **always present**, so asserting its PRESENCE is green on a cell that truncates nothing — plus `title` and an `sr-only` node with the whole text **only when truncated**. **No glyph marks the cut at any width** — the ellipsis is the CSS one, drawn by `truncate` on the text span. Its `aria-label` names the operator IN FULL in both branches, even when the visible text takes the short form |
|
|
388
|
+
| `[data-grid-filter-cell-panel="<id>"]` | The popover of a filter cell. Radix **portals it to `document.body`**, so it is not inside the grid element. Inside: `[data-grid-filter-cell-operator="<id>"]` (the operator `<select>`, also carrying `data-grid-filter-cell-type`) and the atom's editor, named `filter-row-<id>` |
|
|
389
|
+
| `[data-grid-toolbar-filter-row-toggle]` | The «Show / Hide filter row» button, rendered in the toolbar with `filterMode="row"`. `aria-expanded` says whether the ZERO ROW is MOUNTED — since R13 closing it removes the `<tr>` entirely, and this button is the only way back, so it is rendered in both states |
|
|
390
|
+
| `[data-grid-toolbar]` | The toolbar row (Fase 3 **named** it; the row itself predates the phase and P6 renders it always, so it is present even at a minimal call site). `flex flex-wrap items-center gap-2` — `items-center` and no longer `items-end`, because from `adg-m3` the search cell is one line tall like the others. **From `adg-m3` it has TWO groups**: the direct children up to the filter-row indicator (what NARROWS the data), then one `.ml-auto` wrapper holding «columns», the `actions` prop's own buttons (`[data-grid-actions-row]`, from `adg-m3`), the `toolbar.actions` slot and the full-screen toggle, in that order (what changes the VIEW or takes the data out). ⚠️ **`ml-auto` only does work because the search lost `flex-1`**, and the measured four-way table is in the `play` of `AppDataGrid/Confronto`: with the field growing, `ml-auto` present or absent gives the identical layout. So a probe on the group's RIGHT edge alone is vacuous — the group ends flush right in three of the four combinations. Measure the GAP before the group instead: `gap-2` is 8px, and an auto margin that works makes it more |
|
|
391
|
+
| `[data-grid-toolbar-columns]` | The «columns» trigger — opt-in via `toolbar.columns`, and rendered ONLY when the visibility map can actually be written (see `onColumnVisibilityChange`) |
|
|
392
|
+
| `[data-grid-toolbar-columns-panel]` | The popover content holding `GridColumnRegistryPanel`, owned by the grid. Portaled **inside `[data-app-data-grid]`**, not to `<body>` — at full screen a body-level panel sits outside the sentinel cycle and outside `aria-modal`, so its checkboxes are neither tab-reachable nor announced |
|
|
393
|
+
| `[data-grid-toolbar-filters]` | The «filters» trigger — opt-in via `toolbar.filters`, and rendered ONLY when the slot carries `onOpen` or `render`. Also carries `data-grid-toolbar-filter-count`, its own attribute and deliberately **not** the chips row's `data-grid-filter-count`: two nodes sharing one attribute would make an exact `querySelector` ambiguous |
|
|
394
|
+
| `[data-grid-column-registry-item="<id>"]` | One row of the registry — the `visibility + position` pair. **New in Fase 3.** It is the row container, not the `<label>`: the reorder handles cannot live inside a `<label>` (a click on a label descendant toggles the control the label names, so moving a column would hide it). The panel carries one row per **leaf** column, `accessorKey` or not |
|
|
395
|
+
| `[data-grid-column-registry-label="<id>"]` | The `<span>` holding the row's column name. **There is NO `<label>` element**: the toggle is a `<button role="checkbox">` and a `<button>` is not a labelable element, so the name is wired with `aria-labelledby` pointing at this span. The handles sit OUTSIDE it, or clicking one would toggle visibility |
|
|
396
|
+
| `[data-grid-column-registry-toggle="<id>"]` | The visibility checkbox for the row (`role="checkbox"`, DS `Checkbox`, never a native `input`). Its accessible name comes from `aria-labelledby` -> the label span, so it deliberately carries no `aria-label` (an `aria-label` next to visible text is the WCAG 2.5.3 Label-in-Name defect). **`disabled` when the column is visible and it is the last visible one** (`puoNascondere` false) — re-checking a hidden column is never disabled. The prop defaults to `true`, so a standalone mount is unconstrained |
|
|
397
|
+
| `[data-grid-column-registry-visible="true|false"]` | On every row: whether that column is currently visible. Read this instead of inferring visibility from the checkbox node, which is re-created when the row re-renders |
|
|
398
|
+
| `[data-grid-column-registry-position="<n>"]` | On every row: the column's zero-based place in the DECLARED flat order. ⚠️ Sort by THIS, not by DOM order: DOM order is an effect of the rendering choice (today one flat list), while this is the order itself. An assertion that reads DOM order silently starts measuring visibility if the list is ever split or grouped |
|
|
399
|
+
| `[data-grid-column-registry-type="<GridColumnType>"]` | On every row: the column type that selects the row's icon, falling back to `other`. Populated from `GridColumnRegistryEntry.columnType`; a mount that omits it renders one identical icon for every row |
|
|
400
|
+
| `[data-grid-column-registry-block="left|right|scroll"]` | On every row: which pinned block the column belongs to (`pinned ?? "scroll"`). It is what makes the reorder constraint enforceable — a drag is refused when source and target blocks differ |
|
|
401
|
+
| `[data-grid-column-registry-dropzone="before|after|allowed|denied"]`| On a row DURING a drag only, and absent outside the gesture (a permanently present attribute would force the reader to tell «not dragging» from «not accepting», which are two different things). `denied` = the row is in a different block from the dragged one |
|
|
402
|
+
| `[data-grid-column-registry-section="visible"\|"hidden"]` | The section wrapper. TWO sections, labelled and independently scrollable, mirroring the frozen DataTable: `visible` and `hidden`. Hiding a column MIGRATES its row from one section to the other, so React unmounts and remounts it and the node changes identity — a spec that holds a reference across the gesture clicks a detached node. Query the node again after every gesture, and address rows by id rather than by DOM position |
|
|
403
|
+
| `[data-grid-column-registry-section-label="visible"\|"hidden"]` | That section's visible heading (`columnVisibility.visibleColumns` / `columnVisibility.hiddenColumns`) |
|
|
404
|
+
| `[data-grid-column-registry-list="visible"\|"hidden"]` | That section's own scroll container, one per section (`overflow-y:auto` plus a finite `max-height`: without the finite height `auto` never scrolls and an assertion on scrolling is green by absence) |
|
|
405
|
+
| `[data-grid-column-registry-bulk]` | The wrapper for the two bulk actions |
|
|
406
|
+
| `[data-grid-column-registry-hide-all]` | The «hide all» bulk action. Leaves visible ONLY the columns that cannot be hidden, so the last-visible guard still holds afterwards |
|
|
407
|
+
| `[data-grid-column-registry-show-all]` | The «show all» bulk action. Returns exactly to the all-visible state |
|
|
408
|
+
| `[data-grid-column-registry-handle="<id>"]` | The reorder handle `role="group"`. Hosts the arrow-key listener — a keydown bubbles up from either button, so one handler covers both |
|
|
409
|
+
| `[data-grid-column-registry-move-earlier="<id>"]` | Moves the column one position earlier. Native `<button>`: Enter/Space and click both work, and ArrowLeft/ArrowUp do too. Its glyph is a **ChevronUp** — a logical direction, because the i18n deliberately says «earlier»/«later» and a left-pointing chevron would point the wrong way in RTL |
|
|
410
|
+
| `[data-grid-column-registry-move-later="<id>"]` | Moves the column one position later. ArrowRight/ArrowDown as well. Glyph: **ChevronDown**, for the same reason. The horizontal keys stay physical, which is a declared limit: in RTL ArrowLeft moves «later» |
|
|
411
|
+
| `[data-grid-focus-sentinel]` | The two focus sentinels, first and last children **inside** the root, rendered only while fullscreen. `sr-only`, `tabIndex=0`. They are how the trap lives inside the component instead of writing `inert` on siblings it does not own |
|
|
412
|
+
| `[data-grid-empty]` | **Fase 5 — it moved, and it changed nature.** The `<td>` of the EMPTY outcome, inside the `<tbody>`, at the place of the rows: until Fase 4 it was a `<div>` sibling of the table and outside the scrolling viewport. Its `colSpan` equals the number of `<col>`, **ghost column INCLUDED** (`aria-colcount` + 1: that attribute subtracts the ghost by design), it is the ONLY cell of its row, and it carries **no `role`** — the announcement goes through `[data-grid-live-region]`, which stays ONE. `emptyComponent` renders as a descendant of this node |
|
|
413
|
+
| `[data-grid-status-cell]` | **Fase 5.** The same `<td>` as above, marked **unconditionally** with the outcome (`"empty"` / `"error"`): it is the anchor `scripts/check-grid-colspan-perimeter.mjs` admits to the R12 derogation, and the one to use in a test that does not know which of the two outcomes is on screen. Its `<tr>` carries `[data-grid-status-row]` with the same value, plus `role="presentation"` — see the a11y deviations below |
|
|
414
|
+
| `[data-grid-error]` | **Fase 5.** The same `<td>`, on the ERROR branch only (`error` is true and `loading` is false). Present ⇒ `[data-grid-empty]` is absent, and vice versa: the two outcomes never render together |
|
|
415
|
+
| `[data-grid-status="empty"]` / `[data-grid-status="error"]` | **Fase 5.** The visible CONTENT inside that cell (`parts/grid-status-surface.tsx`), one node per outcome. **Neither carries any `role`**: a live region born with its own text inside has no mutation to announce, so it would stay mute — and it would be a second one. The error node's text contains **no digit** (`/\d/` never matches): a figure above a body with no data describes something you cannot see |
|
|
416
|
+
| `[data-grid-status-message]` | **Fase 5.** The `<p>` of the error message alone, without the button's label |
|
|
417
|
+
| `[data-grid-retry]` | **Fase 5.** The «Retry» button. **ABSENT when `onRetry` is omitted.** One click invokes the callback exactly once |
|
|
418
|
+
| `[data-grid-footer]` | **Fase 5.** The `<tfoot>` of the totals, between `</tbody>` and `</table>`. `sticky` at `bottom: 0` — measured: Chromium constrains a sticky to the scroller's CONTENT box, so `0` lands its bottom edge exactly on the top of the horizontal scrollbar, which is the useful bottom. Separator is an inline `box-shadow` (`0 -1px 0 0 var(--d-border)`, the sticky-header token with the sign flipped), **never a border**: under `border-collapse` a border belongs to the TABLE's border grid and scrolls away with the body. `gridZClass.stickyHeader` (z-20), opaque `bg-d-background`. **ABSENT when no visible column declares `aggregate`, and absent above a body with no data** — empty, error or loading |
|
|
419
|
+
| `[data-grid-footer-row]` | **Fase 5.** The `<tr>` of the totals — exactly the height of a data row, from the same map the frame formula reads |
|
|
420
|
+
| `[data-grid-footer-cell="<columnId>"]` | **Fase 5.** One cell per visible column (`"__select"` for the checkbox one), in the ORDER of the body cells and never one for the ghost column. Pinned cells carry `data-grid-pinned="true"` and read the SAME `--grid-pin-*` variables as the body: left/right coincide with the body cell of the same column, right band included |
|
|
421
|
+
| `[data-grid-footer-label]` | **Fase 5.** The label cell carrying the unit (`i18n.table.totalsLabel`), on the FIRST visible column that declares no total. It also carries `data-grid-footer-cell`. **Declared limit:** if EVERY visible column declares a total the row has no label. It is the second node the colSpan gate admits, and today it uses no horizontal extension — the footer renders one `<td>` per column |
|
|
422
|
+
| `[data-grid-pagination]` | The paginator container (Fase 4 filled it; the node predates it). Rendered when **the body has data rows** AND (`paginated` is true OR (`pagination.pageSize` is set AND `groupBy` is absent)). **From Fase 5 the «body has data rows» half is not optional**: above an empty, errored or loading body «Rows 1-10 of 128» is a lie with the air of a detail, so the whole paginator — container, range, indicator and rows clause — is absent. On the client branch it holds ONLY `[data-grid-page-range]` / `[data-grid-row-count]` |
|
|
423
|
+
| `[data-grid-page-indicator]` | The «i / N» indicator, on the SAME two figures the bar navigates on (`currentPage` / `scope.pageCount`, falling back to the engine's count when no total is declared). It used to read `pageIndex + 1` and the ENGINE's count while everything else read the scope's — two derivations of one scope figure in one control, which on the client branch printed «1 / 1» next to «1 2 3 … 13» and «2 / 1» after a click. On the server branch the two coincide by construction (TanStack gets `rowCount: totalItems`), so the three spec files that read it by text (`"1 / 5"` verbatim, `split("/")`) are unchanged. **ABSENT when the paginator is not navigable** (client branch) |
|
|
424
|
+
| `[data-grid-page-range]` | The range — «Rows 1-10 of 128» (Fase 4, NEW node). ASCII hyphen, composed inside `gridResultScope`, never an en dash. Rendered only when the total is KNOWN and non-zero — declared (`resultTotal` / `serverConfig.totalItems`) or, from `adg-m2`, DERIVED on a grid that paginates itself |
|
|
425
|
+
| `[data-grid-row-count]` | The rows clause — «1 row» / «128 rows». Rendered only on ONE page: next to a range it would print the same figure twice. **From Fase 5 never above a body with no data** — the paginator that holds it is not rendered at all there, so the old «or when empty» case is gone |
|
|
426
|
+
| `[data-grid-page-size]` | The «rows per page» `<select>` (values `"10"`, `"25"`, `"50"`, `"100"`; option text `«25 per page»`). Its change dispatches `query/page-size` — one emission, no remount. **The LIVE size always has an option**, even out of scale: `pageSize` is `number`, not `GridPageSizeOption`, so `pageSize={20}` would otherwise leave a controlled `<select>` at `selectedIndex` -1 — an empty selector next to 20-row pages. Absent when not navigable |
|
|
427
|
+
| `[data-grid-page-first]` / `[data-grid-page-last]` | First / last page. Disabled at the ends, never hidden. Absent when not navigable |
|
|
428
|
+
| `[data-grid-page-prev]` / `[data-grid-page-next]` | Previous / next page. They navigate from the CLAMPED page, not from the raw `pageIndex`: with a stale high `pageIndex` (the consumer narrowed `totalItems` without dispatching) «previous» used to emit a `query/page` — a refetch — while nothing moved on screen. Absent when not navigable |
|
|
429
|
+
| `[data-grid-page-number="<n>"]` | One page button, `n` 1-based. Exactly one carries `aria-current="page"` (the STRING, not `true`); its accessible name contains the visible digit. Absent when not navigable |
|
|
430
|
+
| `[data-grid-page-ellipsis]` | The gap between two runs of page numbers — `…` `aria-hidden` plus an `sr-only` name. Absent when the pages are contiguous (e.g. 2 pages) |
|
|
431
|
+
| `[data-grid-sort-index]` | The circular multi-sort priority badge on a leaf header (Fase 4). Its value IS the 1-based position, which the sort button's accessible name also contains |
|
|
432
|
+
| `[data-grid-sort-glyph="ascending\|descending\|none"]` | The sort glyph of a leaf header. Its value IS the direction, so a probe can say which of the three shapes it holds. The two ACTIVE shapes carry `text-d-primary`; `none` (the double chevron) declares no colour and inherits the `<th>`'s — colouring it too would light up every sortable header, i.e. switch off the information the tint exists to give. Same measured contrast price as the filter glyph. ⚠️ **Its box is locked (`shrink-0`)**: in the header's flex row `size-4` declares a PREFERRED width, not a minimum, so a squeezed column used to compress the glyph — measured **5.86px** on a column at the 64px minimum. The label is the only node that gives way there (`truncate`); the glyph, the multi-sort dot and the filter funnel all hold their size |
|
|
433
|
+
| `[data-grid-filter-add-chip]` | The dashed «+ Filter» chip inside `GridFilterChips`, carrying `border-dashed`; its parent is the `role="listitem"` wrapper. Its accessible name is `filters.addFilter` («Add filter»), and its **short side is ≥ 44px** — it was 24 (`badge.sizes.lg` = `h-6`) while the pills next to it in the same row were 44. Outside the letter of the touch-target criterion, which speaks of an APPLIED filter's commands, but it is the entry point of the inventory flow. Same repair on `[data-grid-filter-clear-all]`. **It used to carry `[data-grid-filter-add]`, which is the PANEL's add button**: the selector then matched two nodes and an unscoped `querySelector` took the first in DOM order and seemed to work. Repaired — one attribute, one node |
|
|
434
|
+
| `[data-grid-filter-add]` | The panel's add button (`grid-filter-panel.tsx`), icon-only. Its accessible name NAMES the target column — «Add filter: Category», falling back to `filters.column` before one is chosen — so it no longer shares a name with the chip above |
|
|
435
|
+
| `[data-grid-filter-scope]` | The chip row's counting clause (`role="listitem"`), carrying `data-grid-filter-scope-total` (the raw figure) |
|
|
436
|
+
| `[data-grid-filter-scope-rows]` | «128 rows» / «1 row» — the scope, from the same `GridResultScope` the paginator reads |
|
|
437
|
+
| `[data-grid-filter-scope-filters]` | «1 filter» / «3 filters» — present only with at least one active filter |
|
|
438
|
+
| `[data-grid-filter-panel-toggle]` | **P7.** The disclosure of the lifted inventory (`GridFilterPanel`), carrying `aria-expanded` + `aria-controls`. ⚠️ **This — not `[data-grid-toolbar-filters]` — is the «filters» button every scene of the lane actually renders**, because the scenes pass `toolbar.filters.render` (the only graft that mounts the inventory INSIDE `[data-app-data-grid]`). The grid's own default trigger exists and counts, but it is rendered only when the slot carries `onOpen` and no `render`. A probe that wants «the filters button on screen» must ask for this one; asking for the other returns 0 nodes and passes for the wrong reason. **From `adg-m3` it carries the active-filter count** |
|
|
439
|
+
| `[data-grid-filter-panel-count]` | **`adg-m3` (scostamento 18).** The count badge on that toggle, whose attribute value is the number. Absent — the node does not exist — when the count is 0: a circle with «0» in it claims something is there while saying nothing is. The number comes from `gridActiveFilterCount`, the one place where the choice between the two possible populations is written (decision 27: what is counted is what is SEEN), never from `badges.length` at the call site. The dress is `gridToolbarCountBadgeClass`, shared with the grid's own trigger, and uses **only named Tailwind steps** — `h-4 min-w-4 px-1`, never a bracketed value: the library ships two CSS bundles built by two Tailwind majors and `scripts/check-grid-css.mjs` is the executable form of that ban. `min-w-4` keeps it round at one digit; `px-1` grows it into a pill at two, which is the «sized for its content» half of the criterion and is measured as a RELATION between the two widths, not as a class |
|
|
440
|
+
| `[data-grid-filter-panel-body]` | **P7 — this node is now the OVERLAY.** The panel body: `absolute`, `w-72` (288px), `z-50` from `gridZClass.overlay`, anchored to the panel root's **right** edge (`right-0`), painting **over** the table band. ⚠️ **Right and not left**, and the reason has a HISTORY: when the anchor was chosen the filters slot sat at the RIGHT end of the toolbar, so a left anchor grew the 288px outwards and off-screen — measured in full screen, `left 1107.23 → right 1395.23` on a 1200px window, 195px of the 288 outside the viewport and `document.elementFromPoint` on its centre returning `null`. **From `adg-m3` the slot is no longer at the right end**: «columns» moved into the toolbar's `.ml-auto` group and the filters slot stayed in the left one, third child. The right anchor still holds and is now the safer of the two — re-measured after the move, the body renders `left 287 → right 575` on a 900px window, fully inside it, with `elementFromPoint` on its centre returning the panel. ⚠️ **But the anchor is now load-bearing in the OPPOSITE direction**: pushing the slot further left (the mockup puts it second, right after the search — still an OPEN decision, see `adg-m3`) walks the 288px towards the left edge, and there `right-0` becomes the anchor that overflows. Whoever moves it re-measures this node. ⚠️ **Re-measured once more for the `actions` buttons, and the anchor could not have moved:** `right-0` resolves against the panel's OWN root `[data-grid-filter-panel]` (which carries `relative` for exactly this), and that root is emitted by the `toolbar.filters` branch, in the LEFT group. Content added to the RIGHT group changes where that group STARTS, never where the left group's children sit. The only residual coupling is the wrapping of a `flex-wrap` bar, which is what the two-or-more collapse keeps small. `null` when closed, never `display:none`. It used to be an in-flow block and opening it pushed the grid down; the spec measures the effect and not the class — **four** independent quantities on the same overlay: the rendered width, the geometric **intersection** with the viewport and with at least one leaf cell (the plane), the node hit by `elementFromPoint` at its centre (the stack), and the first data row's `top` plus the count of fully visible leaf cells, both changing by **exactly 0** across the open. The three «is it ON TOP» quantities arrived with the phase verification: without them a panel opening **upwards**, entirely outside the band, stayed green. ⚠️ It must be rendered INSIDE `[data-app-data-grid]` (mount it through `toolbar.filters.render`): outside the root, in full screen the `fixed inset-0` covers it and the two focus sentinels exclude it from the tab cycle — invisible AND unreachable, with no error |
|
|
441
|
+
| `[data-grid-filter-search]` | **P7.** The inventory's search field (the `<input>` inside the `Input` primitive, since `data-*` reach the input and not the wrapper). The focus **enters** here when the panel opens and **returns** to whoever had it when the panel closes — not to a trigger chosen a priori, because this state has two triggers (the panel toggle and the dashed «+ Filter» chip) |
|
|
442
|
+
| `[data-grid-filter-inventory]` | **P7.** The list of AVAILABLE filters — `role="group"`, `aria-label = filters.availableFilters`. ⚠️ Count its entries with the SCOPED selector `[data-grid-filter-inventory] [role="checkbox"]`: the column registry, when rendered inside the panel, uses NATIVE `<input type="checkbox">`, which a `getAllByRole("checkbox")` would include and a `querySelectorAll('[role="checkbox"]')` would not — the two probes give different numbers on the same DOM |
|
|
443
|
+
| `[data-grid-filter-inventory-item="<id>"]` | **P7.** One entry, `role="checkbox"` + `aria-checked`. Ticked = at least one applied filter names that column; the state is READ FROM THE BADGES, so the two lists cannot diverge. The tick is a SHAPE (an outlined box, a check mark) and never a colour |
|
|
444
|
+
| `[data-grid-filter-inventory-scroll]` | **P7.** The part that scrolls. The primary action lives OUTSIDE it — that is the whole of «the primary action does not scroll away» |
|
|
445
|
+
| `[data-grid-filter-inventory-empty]` | **P7.** `filters.noFilterableColumns` when no column is filterable at all, `notFoundMessage` when the search found nothing |
|
|
446
|
+
| `[data-grid-filter-build]` | **P7.** «Build a condition», the panel's primary action. `disabled` when `onBuildCondition` is absent rather than hidden: the criterion measures its POSITION, so the node has to exist always |
|
|
447
|
+
| `[data-grid-filter-row-type]` | **P7.** On the operator `<select>` of an applied row: the `filterType` that PRODUCED that list, declared in the DOM so a spec derives the expectation from the DOM instead of copying it by hand |
|
|
448
|
+
| `[data-grid-filter-editor="<name>"]` | **P7.** The root of the value ATOM — a snug `<span>` (no padding, no gap, one child), so its rectangle is the control's rectangle. Carries `data-grid-filter-editor-kind` (`text\|number\|boolean\|date\|datetime\|select\|multiSelect`, the editor DERIVED and read back from the DOM), `data-grid-filter-editor-type` (the `filterType`) and `data-grid-filter-editor-arity` (`single\|list`; with arity `none` — `eq_null`, `checked` — the atom renders NOTHING) |
|
|
449
|
+
| `[data-grid-filter-editor-control="<name>"]` | **P7.** The focusable control inside the atom (`<input>` / `<select>`). ⚠️ **Absent for `date` and `datetime`**: `DatePicker` accepts no `data-grid-*`, so there the control is the only `[role="button"]` inside the root and carries `data-testid="<name>"`. On a panel row the same node also carries the historical `[data-grid-filter-row-value="<columnId>"]`, which three already-green specs type into |
|
|
450
|
+
| `[data-grid-filter-editor-placeholder]` | **P7.** The empty `<option>` of a select editor. Enumerate a column's choices as `select.querySelectorAll("option:not([data-grid-filter-editor-placeholder])")` |
|
|
451
|
+
| `[data-grid-filter-editor-unknown]` | **AUDIT 2026-09-09.** The synthetic `<option>` a list editor renders when the badge's value is NOT among the options the column declares. Without it the `<select>` fell back to the placeholder while the filter was active, so an applied filter looked empty. Enumerating a column's DECLARED choices therefore excludes it too: `select.querySelectorAll("option:not([data-grid-filter-editor-placeholder]):not([data-grid-filter-editor-unknown])")` |
|
|
452
|
+
| `[data-grid-filter-editor-empty]` | **P7.** A list editor whose column declares ZERO options. It is an INCOMPLETE DECLARATION and is marked as such — never a reason to fall back to a free text field, which would put back the very defect the phase repairs |
|
|
453
|
+
| `[data-grid-filter-composer="<columnId>"]` | **P7.** The «from … to …» composer (a `PopoverContent`, portaled — look for it in the `document`, not in `canvasElement`). Its draft is LOCAL: `Escape`, «Cancel» and a click outside throw it away without touching the state or the emission counter; `[data-grid-filter-composer-apply="<columnId>"]` is the only gesture that promotes it. Also carries `[data-grid-filter-composer-title]`, `[data-grid-filter-composer-side="from\|to"]` (whose editors are named `<columnId>-from` / `-to`) and `[data-grid-filter-composer-cancel="<columnId>"]` |
|
|
454
|
+
| `[data-grid-filter-chip-trigger="<columnId>"]` | **P7 — the chip is now ONE target.** The pill's `<button>`; accessible name «Condition on <column>: <operator> <value>», short side ≥ 44px. It also carries `data-grid-filter-chip-operator="<operator>"`, the operator id declared in the DOM. ⚠️ Inside `[data-grid-filter-chip]` there is **exactly one** focusable node — count them, do not query the accessible name: with the old `✕` still there the name query stays green because the `✕` is called something else |
|
|
455
|
+
| `[data-grid-filter-chip-edit="<columnId>"]` | **P7.** The «Edit value…» menu item; opens the composer through `onEditValue`, `disabled` without it. ⚠️ **Portaled**: look for it in the `document` |
|
|
456
|
+
| `[data-grid-filter-chip-remove="<columnId>"]` | **P7 — same name, different node.** No longer the `✕` in the canvas: it is the «Remove» menu item, `data-variant="destructive"`, separated from «Edit value…» by a `[role="separator"]`. ⚠️ **Portaled** out of `canvasElement`. Both items are ≥ 44px on the short side |
|
|
457
|
+
| `[data-grid-header-filtered="<columnId>"]` | **P7.** The glyph on the header of a CONSTRAINED column — a funnel `role="img"` whose accessible name is `filters.columnFiltered`. **Shape plus name FIRST, colour added** (WCAG 1.4.1): the discriminator is still the funnel outline no other header carries, plus the accessible name; the accent tint (`text-d-primary`) is added to those, not put in their place, so a reader who does not distinguish the hue still reads the shape. A probe must key on `[data-grid-header-filtered]`, never on the colour — the token is consumer-overridable. ⚠️ **Measured price:** in the light theme `--d-primary` resolves `#64c2d1` on `#fafafa` = 1.86:1, under the 3:1 WCAG 1.4.11 asks of a graphical object, and paler than the `#7a7a7a` (3.94:1) the glyph had by inheritance. It holds only because colour carries no state here; a theme wanting 3:1 on the glyph moves `--d-primary` one step darker (measured: `#3e8b97` = 3.54:1). It is not a button: the filter is removed and edited from its own pill. It is driven by the SAME list the filter count reads, so «filtered» in the header and the chip that names it cannot diverge |
|
|
458
|
+
| `[data-grid-selection-bar]` | **Fase 6.** The page-selection bar — `role="group"` + `aria-labelledby` pointing at the visible count, **never `role="status"`** (the mockup's `CompleteSelectionBar` carries `role="status" aria-live="polite"`; the component does NOT, because the invariant is ONE live region per grid — `UnaSolaRegioneLive` counts them). **In flow ABOVE the scroll area since 2026-09-03**, between the filter band and the table band; its wire is therefore `gridStickyHeaderShadow` (downward, `0 1px`) and no longer the footer's upward one. ⚠️ **The node does not exist while nothing is selected** — not "exists with height 0": the frame is a `flex flex-col gap-2`, so an always-rendered wrapper would still cost 8px of gap, i.e. one data row for ever. Its height comes from `gridRowHeightClass[density]`, the same map the `selectionBar` addend of `gridFrameHeight` reads. **See: "Declared limit — in fullscreen the band is paid by the viewport"** below — the no-lost-row promise holds for a bounded frame, and in fullscreen it costs one row by decision |
|
|
459
|
+
| `[data-grid-selection-count]` | **Fase 6.** The visible count — «2 rows selected». Carries the `id` the bar's `aria-labelledby` points at, and the number as its attribute value. The figure is `engine.selectedPageRowIds.length`, i.e. the ticked ids the **current row model** still knows — never `Object.keys(rowSelection).length`, which survives paging and filtering. ⚠️ **From M4 it holds the count ALONE** — «2 rows selected» — because the scope moved into the sibling `[data-grid-selection-scope]`; it is also where the bar's typography starts, `text-sm font-medium` (14px / 500) as in the mockup |
|
|
460
|
+
| `[data-grid-selection-scope]` | **M4.** The scope of the selection — «of 8 on this page» — a DIRECT child of the bar and a sibling of the count, never inside the totals band nor inside `[data-grid-selection-summary]`: in there it would disappear with them, while «out of how many rows» is a property of the selection and not of the totals. `text-xs` (12px), the mockup's second step. **The separator lives INSIDE this node's string** and never as a bare text node between siblings, so it disappears together with what it introduces when no column aggregates and no `selection.summary` renders |
|
|
461
|
+
| `[data-grid-selection-aggregate]` | **Fase 6.** The bar's totals band. **This is the node to compare with `[data-grid-footer]`**, not the whole bar: the bar also holds the count and the buttons. Its cells come from `engine.selectionCells` = `aggregateCells(selectedPageRowIds)`, the same `cellsForRows` → `applyGridAggregate` that feeds the `<tfoot>`, so with the whole page ticked the two `textContent` are equal by construction |
|
|
462
|
+
| `[data-grid-selection-cell="<columnId>"]` | **Fase 6.** One cell of the totals band, in the footer's own column order |
|
|
463
|
+
| `[data-grid-selection-label]` | **Fase 6.** The label cell of the totals band — the first column **without** an aggregate, same rule as `[data-grid-footer-label]` |
|
|
464
|
+
| `[data-grid-selection-summary]` | **Fase 6.** Rendered **instead of** the totals band when `selection.summary` is passed |
|
|
465
|
+
| `[data-grid-selection-actions]` | **Fase 6.** The container of the bulk actions |
|
|
466
|
+
| `[data-grid-selection-action="<label>"]` | **Fase 6.** One bulk-action button. Visible text is `label + count`; the **accessible name extends it** with the unit read next to the number («Delete 5 rows selected», WCAG 2.5.3), composed at render so it changes on every tick |
|
|
467
|
+
| `[data-grid-selection-destructive="true"]` | **Fase 6.** Present only on the destructive action (`variant="destructive"` of the shared `Button`) |
|
|
468
|
+
| `[data-grid-selection-dismiss]` | **Fase 6.** The way out. Value `"dismiss"` with a partial selection («Cancel») and `"deselect-all"` when the whole page is ticked («Deselect all») |
|
|
469
|
+
| `[data-grid-select-all]` | **Fase 6 gave it a value.** The header checkbox — `"all"` \| `"indeterminate"` \| `"none"` (it used to be the empty string). It also carries `aria-checked="mixed"` when and only when the selection is partial. ⚠️ **Do not assert `data-state`**: the shared `Checkbox` passes `false` to Radix for `"indeterminate"`, so `data-state` says `unchecked` on a visibly mixed box — the ARIA attribute is overridden from the call-site instead of touching the shared primitive |
|
|
470
|
+
| `[data-grid-group-select="<path>"]` | **Fase 6.** The checkbox of a **group row**. It is Radix's `<button role="checkbox">`, so query it directly (not `[…] button`). Works on a **collapsed** group: the leaf ids come from `GridGroupRowView.leafIds`, collected on the tree above the flattener's `if (!expanded) continue`, never from the DOM |
|
|
471
|
+
| `[data-grid-group-select-cell="<path>"]` | **Fase 6.** The `<td>` of the `__select` column on a group row — previously an empty placeholder |
|
|
472
|
+
| `[data-grid-group-select-state]` | **Fase 6.** `"all"` \| `"indeterminate"` \| `"none"` on the group checkbox, the analogue of `data-grid-select-all` |
|
|
473
|
+
| `[data-grid-toolbar-custom-filters]` | **Fase 8.** The DASHED GROUP of the filters without a column, inside `[data-grid-toolbar]`. `role="group"`, named by `i18n.filters.customFilters`, and it carries `data-grid-toolbar-custom-filter-count`. ⚠️ **With zero custom filters the node does not exist** (it renders `null`, not an empty dashed rectangle), so a `querySelectorAll(...).length` on a scene without custom filters is 0 and passes for the wrong reason — demand the node first. ⚠️ It is NOT `[data-grid-filter-add-chip]`, the other dashed node of the lane: tell them apart by attribute and by container, never by the `border-dashed` class, and measure the dashing with `getComputedStyle` (`borderStyle === "dashed"` AND `borderWidth !== "0px"`) — the class alone is green on a border of zero pixels |
|
|
474
|
+
| `[data-grid-toolbar-custom-filters-caption]` | **Fase 8.** The VISIBLE caption of that group: `` `${count} ${i18n.filters.withoutColumn}` `` → «2 senza colonna». It is the on-screen half of criterion 7 — the status-line tail says the same sentence but lives inside an `sr-only` node, so a probe reading its `textContent` stays green on something nobody can see. Measure this one geometrically (`getBoundingClientRect().width`): `sr-only` would take it to 1px |
|
|
475
|
+
| `[data-grid-toolbar-custom-filter="<badge id>"]` | **Fase 8.** One chip of that group, anchored to the badge `id` and **not** to `customKey`: two custom filters of the same type share the registry key, and two nodes with the same attribute make a `querySelector` take the first one |
|
|
476
|
+
| `[data-grid-toolbar-custom-filter-label]` | **Fase 8.** The full name of a filter without a column. It contains `customLabel` and NOTHING else — the return value of `describe`, never a recomposition. Not truncated: «in full» is literal, and the group wraps |
|
|
477
|
+
| `[data-grid-toolbar-custom-filter-remove="<badge id>"]` | **Fase 8.** Its «×»: a real `<button type="button">` with **no explicit `role=`** (an explicit role replaces `button` in the computed role, and `getAllByRole("button", …)` would return 0). Accessible name = `` `${i18n.filters.removeFilterNamed} ${customLabel}` `` → «Togli il filtro …» in Italian, «Remove filter …» by default. Calls `onRemoveFilter(badge.id)` |
|
|
478
|
+
| `[data-grid-toolbar-custom-filter-unknown]` | **Fase 8.** Present on a chip whose `customKey` is not a key of `filterControls` — a badge that did not come from `createGridCustomFilterBadge`. Absent on every chip when `filterControls` is not passed: without a registry there is nothing to recognise, and marking them all would be a false positive |
|
|
479
|
+
| `[data-grid-filter-row-kind="custom"]` | **Fase 8.** Marks an APPLIED PANEL ROW belonging to a filter without a column. Such a row shows the name (`[data-grid-filter-row-column]`, again `customLabel`) and its «×», and renders **no operator select and no value editor** — a badge with `columnType: "other"` and no `filterType` has no vocabulary, so those controls would be inert. This is why «12 filters, 10 filled cells» is a fact of the component and not of the story |
|
|
480
|
+
|
|
481
|
+
### The toolbar order, and the one decision still open (`adg-m3`)
|
|
482
|
+
|
|
483
|
+
The row of controls above the table is a **fixed order in the JSX** — no prop reorders it, and none is
|
|
484
|
+
planned: nobody asked for one, and it would be configurability without a caller.
|
|
485
|
+
|
|
486
|
+
What it renders, in DOM order:
|
|
487
|
+
|
|
488
|
+
| # | Node | Group |
|
|
489
|
+
| --- | --------------------------------------------------------------------------------------------------------------- | ------------------ |
|
|
490
|
+
| 1 | the two bypass links (`absolute`, zero layout cost) | left |
|
|
491
|
+
| 2 | `[data-grid-search]` — label beside the field, magnifier inside, no longer growing | left |
|
|
492
|
+
| 3 | ~~`[data-grid-density-control]`~~ — **removed from the toolbar by `adg-c12`**; the grid renders no density control | — |
|
|
493
|
+
| 4 | the filters slot — `[data-grid-filter-panel-toggle]` with `render`, `[data-grid-toolbar-filters]` with `onOpen` | left |
|
|
494
|
+
| 5 | `[data-grid-toolbar-custom-filters]` — the dashed group, only with lifted filters AND `onRemoveFilter` | left |
|
|
495
|
+
| 6 | `[data-grid-toolbar-filter-row-toggle]` — only in `filterMode="row"` | left |
|
|
496
|
+
| 7 | `[data-grid-toolbar-columns]` | right (`.ml-auto`) |
|
|
497
|
+
| 8 | `[data-grid-actions-row]` — the `actions` prop's buttons: ONE inline, `[data-grid-actions-trigger]` from two up | right |
|
|
498
|
+
| 9 | the `toolbar.actions` slot — the consumer's own content, no default node | right |
|
|
499
|
+
| 10 | `[data-grid-fullscreen-toggle]` | right |
|
|
500
|
+
|
|
501
|
+
**«Esporta» before «Schermo intero», and the target is the MOCKUP.** The code used to emit `fullscreen`
|
|
502
|
+
before `actions`. `PIANO-COMPLETAMENTO.md` row 27 writes «Esporta → Schermo intero» for the mockup, and the
|
|
503
|
+
mockup lane of `AppDataGrid/Confronto` **confirms it on the DOM**: `search → filtri → densità → colonne →
|
|
504
|
+
esporta → fullscreen`. The two sources agree; the code was the only one of the three that diverged, so there
|
|
505
|
+
was no inversion to arbitrate. ⚠️ **`adg-m3` also inserted the `actions` prop's own buttons between «columns» and the consumer slot** — they used to be a row of their own ABOVE this bar. The mockup's three-node order is preserved («Colonne» → «Esporta» → «Schermo intero»): the compat buttons sit INSIDE it, not above it, and collapsed into one trigger from two actions up so the group grows by one h-6 node and not by three.
|
|
506
|
+
|
|
507
|
+
**What is STILL OPEN: where the filters slot goes.** The mockup puts it **second**, immediately after the
|
|
508
|
+
search; here it is fourth (third among the visible nodes). It is not a free reorder, and the cost is written
|
|
509
|
+
into two places:
|
|
510
|
+
|
|
511
|
+
- the inventory's overlay body is anchored `right-0` (see `[data-grid-filter-panel-body]`), so walking the
|
|
512
|
+
slot further left walks 288px towards the left edge of the window — the anchor that protects it today
|
|
513
|
+
becomes the anchor that overflows;
|
|
514
|
+
- the two nodes the mockup does not have at all — the filter-row toggle and the off-screen-filters
|
|
515
|
+
indicator — sit after the slot, so moving it changes what they read as belonging to.
|
|
516
|
+
|
|
517
|
+
The decision belongs to whoever ran the mockup comparison, not to an agent. The `play` of
|
|
518
|
+
`AppDataGrid/Confronto` asserts the current order as a **literal**, precisely so that the day the slot moves
|
|
519
|
+
that line turns red and whoever moved it updates it, instead of leaving the comparison green on an order
|
|
520
|
+
nobody looked at again.
|
|
521
|
+
|
|
522
|
+
### Column widths, resize and pinning (P4)
|
|
523
|
+
|
|
524
|
+
Widths, resizing and left-pinning need no prop on the grid: they are declared **per column** (`width`,
|
|
525
|
+
`pinned: "left"`), which is the same field the frozen `DataTable` exposes, and every leaf column carries its
|
|
526
|
+
own handle. Resizing is a **view** action, so it can never reach `onQueryChange` — that is R13 (“a column
|
|
527
|
+
resize must not cause a refetch”) as a property of the action type rather than a rule someone has to
|
|
528
|
+
remember.
|
|
529
|
+
|
|
530
|
+
**A committed width is now observable**, through `onColumnWidthsChange`: the callback fires **once per
|
|
531
|
+
commit** — on `pointerup` for a drag (never during it, never on `pointercancel`) and **once per keypress**
|
|
532
|
+
on a focused separator — and hands over the whole, partial widths map. It is still a view action, so
|
|
533
|
+
`data-emissions` does not move. Two things to know before you persist it: a **zero-delta** click on the
|
|
534
|
+
handle commits and therefore notifies, and the number handed over is the **rendered** width, i.e.
|
|
535
|
+
post-slack-distribution and so viewport-dependent. Both are declared on the prop.
|
|
536
|
+
|
|
537
|
+
| Attribute | Where | Value |
|
|
538
|
+
| ------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
539
|
+
| `style.width` | `[data-grid-col="<id>"]` | Always present, in px. During a drag it is written **directly to the DOM** |
|
|
540
|
+
| `aria-valuenow` | `[data-grid-resize-handle="<id>"]` | The column's current width; `aria-valuemin` 64, `aria-valuemax` 1200 |
|
|
541
|
+
| `data-render-count` | `[data-app-data-grid]` | React commits of the **root** since mount. Rises by **exactly 1** over a drag |
|
|
542
|
+
| `data-grid-table-commits` | `[data-grid-table]` | React commits of the **table subtree**. Also exactly 1 over a drag. ⚠️ **From `adg-m3` it STARTS at 2, not 1** — the horizontal measurement commits once in a `useLayoutEffect`; the drag DELTA is unchanged, because the viewport's width does not move during a drag |
|
|
543
|
+
| `left` | `[data-grid-pinned="true"]` | Left band only: `var(--grid-pin-N)`, N being the position among the columns pinned left |
|
|
544
|
+
| `right` | `[data-grid-pinned="true"]` | Right band only: `var(--grid-pin-right-N)`, N being the position among the columns pinned right, **in column order** |
|
|
545
|
+
| `--grid-pin-N` | `[data-grid-table]` | The left offsets, computed from the **declared** widths — no measuring |
|
|
546
|
+
| `--grid-pin-right-N` | `[data-grid-table]` | The right offsets, same arithmetic accumulated from the **right** edge: the last pinned column is `0` |
|
|
547
|
+
|
|
548
|
+
Measured invariants (see `docs/analysis/datatable-v2/VERDETTO-P4.md`):
|
|
549
|
+
|
|
550
|
+
- A pointer drag commits **once**, at release. Thirty `pointermove` events produce zero commits: the width
|
|
551
|
+
goes straight onto the `<col>` and the pinned offsets onto one CSS variable on the `<table>`.
|
|
552
|
+
- Keyboard resizing is 8px per `ArrowLeft`/`ArrowRight`, one commit per press, `aria-valuenow` following.
|
|
553
|
+
- Pinned offsets are correct at the **first commit**, before any paint. They are arithmetic over declared
|
|
554
|
+
widths, not a measurement — which is the difference from `DataTable`, whose offsets arrive from a
|
|
555
|
+
`useState` filled after the first paint.
|
|
556
|
+
- Horizontal overflow is born, not configured: `[data-grid-viewport]` scrolls on both axes, and the
|
|
557
|
+
bar appears as soon as the declared widths exceed it. The page itself never gains a horizontal scrollbar.
|
|
558
|
+
|
|
559
|
+
⚠️ **The two scrollbar lanes are reserved as padding on `[data-grid-viewport]`, and this changes two numbers
|
|
560
|
+
a test may read.** Both bars are always visible (`type="always"`) and both are `position: absolute` — at
|
|
561
|
+
`bottom: 0` and `right: 0` — so they paint _over_ the content unconditionally, with no overflow test. The
|
|
562
|
+
viewport therefore carries `padding-bottom` and `padding-right` of one lane each (10px, `gridScrollbarLaneSize`
|
|
563
|
+
in `grid-tokens.ts`). Consequences:
|
|
564
|
+
|
|
565
|
+
- `clientHeight` / `clientWidth` are **unchanged** — padding is inside them — so `viewportHeight` remains the
|
|
566
|
+
contract: `[data-grid-viewport].clientHeight === viewportHeight`.
|
|
567
|
+
- ⚠️ **And `adg-m3` adds a READER of that same fact on the horizontal axis.** The column-width distribution
|
|
568
|
+
needs the width available to the `<table>`, which is `[data-grid-viewport].clientWidth` **minus one lane**:
|
|
569
|
+
the lane is padding, so it is inside `clientWidth` but outside the content box the table's `w-full` resolves
|
|
570
|
+
against. It subtracts `gridScrollbarLaneSize` — the same token that writes the padding in
|
|
571
|
+
`grid-scroll-area.tsx` — so the two numbers cannot drift.
|
|
572
|
+
- `scrollWidth` / `scrollHeight` are **one lane larger** than the content, because a scroll container's padding
|
|
573
|
+
is part of its scrollable overflow region. A test asserting an absolute `scrollWidth` must add
|
|
574
|
+
`gridScrollbarLaneSize`; relational assertions (`scrollWidth > clientWidth`, "constant across frames") are
|
|
575
|
+
unaffected.
|
|
576
|
+
- The last row and the right-pinned band therefore end **at the bar's edge**, not under it. Measure clearance
|
|
577
|
+
from the bar (`[data-slot="grid-scroll-area-scrollbar"][data-orientation="…"]`), never from the viewport's
|
|
578
|
+
border box: the border box is what the bar covers, so `viewport.right - cell.right === 0` is the _overlap_
|
|
579
|
+
condition, not its negation.
|
|
580
|
+
|
|
581
|
+
⚠️ **`colgroup` holds one `<col>` more than there are columns.** It has no width and no cells, and it is
|
|
582
|
+
required: measured in Chromium, `table-layout: fixed` with `width: 100%` **inflates** declared widths when
|
|
583
|
+
their sum is below the table width (four 200px columns in a 1000px box render at 250px each), and on inflated
|
|
584
|
+
widths the pinned offsets cannot be arithmetic. The extra `<col>` absorbs the slack, so declared widths stay
|
|
585
|
+
exact in both regimes. It is excluded from `[data-grid-col]` on purpose: a test counting columns must count
|
|
586
|
+
`[data-grid-col]`, and assert `colgroup col === [data-grid-col] + 1` separately. ⚠️ **From `adg-m3` the
|
|
587
|
+
filler no longer absorbs ALL of the slack in the underflow regime** — see the paragraph below: most of it is
|
|
588
|
+
distributed to the columns and written to their `<col>`, and this node keeps the remainder. Nothing about the
|
|
589
|
+
sentence above changes, because the distributed number is a DECLARED width like any other.
|
|
590
|
+
|
|
591
|
+
⚠️ **Columns fill the container by default (`adg-m3`), and the escape hatch is the per-column `width`.**
|
|
592
|
+
Measured defect at 1920×1080 on the consumer's `/user-management/permission/`: a **458px** empty strip on the
|
|
593
|
+
right of the table, swallowed by the phantom `<col>`, because every column had fallen back to
|
|
594
|
+
`gridColumnWidth.default` (160) and nothing added up to the viewport. The grid now measures the viewport
|
|
595
|
+
(one `ResizeObserver` per grid, in `parts/grid-table.tsx`) and, when the sum of the resolved widths is BELOW
|
|
596
|
+
the width available to the `<table>`, divides the leftover across the columns that took the default —
|
|
597
|
+
`distributeGridColumnSlack` in `grid-tokens.ts`, a pure function. **Four rules bound it, and each one is
|
|
598
|
+
traceable to something on the code path rather than to taste:**
|
|
599
|
+
|
|
600
|
+
1. **Never in the overflow regime.** If the sum EXCEEDS the container the slack is negative and nothing is
|
|
601
|
+
touched: that is where the horizontal scrollbar is born («horizontal overflow is born, not configured»,
|
|
602
|
+
in the invariants above), and narrowing columns to make it disappear would remove exactly that bar.
|
|
603
|
+
2. **Never over a column with a declared `width`.** That is a statement, not a default — whoever writes
|
|
604
|
+
`width: 90` on a column of flags does not want 155 — and it is also what keeps the lane's pixel
|
|
605
|
+
assertions valid, since its stories declare their widths.
|
|
606
|
+
3. **Never over a width the user has DRAGGED** (`view/column-width`): re-flowing it would undo the gesture on
|
|
607
|
+
the next render, i.e. a handle that does not hold.
|
|
608
|
+
4. **The distributed number is WRITTEN**, never left to fixed-layout inflation, so declared === rendered and
|
|
609
|
+
the pinned offsets stay exact arithmetic at the first paint. Distribution is by `Math.floor`, so fewer px
|
|
610
|
+
than the number of elastic columns go to nobody and the filler takes them; a `Math.ceil` would overshoot
|
|
611
|
+
the sum by a pixel and conjure a scrollbar out of nothing. `clampGridColumnWidth` still applies, so no
|
|
612
|
+
column passes `gridColumnWidth.max`, and what the ceiling refuses returns to the filler.
|
|
613
|
+
|
|
614
|
+
The consequence to know: **`width` is now also the opt-OUT of filling**, which is coherent but was never
|
|
615
|
+
designed as one — a grid whose EVERY column declares a width keeps its dead strip, and that is the one case
|
|
616
|
+
the default does not reach. Knowing side effect of the same rule: dragging a column narrower frees slack that
|
|
617
|
+
the remaining elastic columns absorb at `pointerup`, in the one commit the drag already had.
|
|
618
|
+
|
|
619
|
+
⚠️ **Provenance of the two numbers, because this document does not launder an inference into a measurement:**
|
|
620
|
+
the 458px strip was measured on the live consumer BEFORE the fix. That the strip is GONE is so far a
|
|
621
|
+
consequence read off the code path, not an observation — it needs a `dist/` rebuild and a copy into the
|
|
622
|
+
consumer to become one. The observation to make there: on `/user-management/permission/` at 1920px every
|
|
623
|
+
`[data-grid-col]` should carry an inline width above 160, `[data-grid-col-filler]` should measure a few px,
|
|
624
|
+
and `viewport.scrollWidth === viewport.clientWidth`.
|
|
625
|
+
|
|
626
|
+
⚠️ **Pinning honours declaration order, not position.** Offsets accumulate over the pinned columns in the
|
|
627
|
+
order they sit among the columns, so pinning the third column and not the first two glues it to the left edge
|
|
628
|
+
and lets the first two scroll **under** it. This is the frozen table's semantics too; column reordering is
|
|
629
|
+
P10, and until then pinning a non-prefix is a configuration with no sensible appearance. The right band is the
|
|
630
|
+
mirror image: pin a **suffix**, or the columns to its right scroll under it.
|
|
631
|
+
|
|
632
|
+
⚠️ **The right band is only visible under horizontal overflow, and that is not a limitation.** Without overflow
|
|
633
|
+
the slack goes to the elastic columns and its remainder to the extra `<col>` (`adg-m3`; before it, all of it
|
|
634
|
+
went to the `<col>`), so in either case the declared widths stay exact and the pinned block already sits flush
|
|
635
|
+
against the right edge — which is where a right-pinned column belongs, and where nothing scrolls under it. The
|
|
636
|
+
filler `<col>` stays **last** in the `colgroup` in both regimes: a `<col>` maps to cells by position, so moving
|
|
637
|
+
it earlier would make it swallow a real column's slot. Under overflow it collapses to zero, and there a right
|
|
638
|
+
offset of `0` coincides exactly with the right edge of the column's own `<col>`.
|
|
639
|
+
|
|
640
|
+
⚠️ **Measuring the right band needs a scroll too, and the vacuous point is the OPPOSITE one.** For the left band
|
|
641
|
+
a cell at `scrollLeft: 0` sits at its natural position whatever its offset is, so every left-pinning assertion
|
|
642
|
+
has to be taken after scrolling. On the right axis the free point is `scrollLeft` **maximum**: there the
|
|
643
|
+
viewport's right edge coincides with the table's, so a right-pinned column sits naturally and any offset gives
|
|
644
|
+
the same number. Measured on 14 columns summing 1888px in a 1422px viewport: at `scrollLeft: 0` the last pinned
|
|
645
|
+
cell is pulled back by exactly 466px (the maximum scroll) relative to its own `<col>`; at maximum scroll the two
|
|
646
|
+
coincide. Assert both, and know which is which.
|
|
647
|
+
|
|
648
|
+
⚠️ **A group pins to the right at the offset of its LAST leaf**, mirroring the left rule (the first leaf's). With
|
|
649
|
+
the first leaf's offset a two-column group would step back too far and cover columns it does not head. And a
|
|
650
|
+
group whose leaves are pinned to **different** sides is rendered free: it has no single edge to anchor to, while
|
|
651
|
+
each of its leaves stays pinned on its own side.
|
|
652
|
+
|
|
653
|
+
⚠️ **`aria-valuenow` during a drag is written imperatively.** It does not come from React state until the
|
|
654
|
+
release, for the same reason the width does not. A test that reads it mid-gesture is reading the DOM, which is
|
|
655
|
+
the point; one that expects a React re-render will find none.
|
|
656
|
+
|
|
657
|
+
⚠️⚠️ **THE `⋮` COLUMN MENU HAS BEEN REMOVED, AND WITH IT WCAG 2.5.7 FOR COLUMN RESIZE — declared debt, not an
|
|
658
|
+
oversight.** Until this change every column carried a `⋮` menu (P4 added it to non-sortable columns too), and
|
|
659
|
+
its two items `data-grid-resize-wider` / `-narrower` were the **single-pointer alternative without dragging**
|
|
660
|
+
that WCAG 2.2 SC 2.5.7 (Dragging Movements, AA) requires — keyboard arrows do not satisfy that criterion,
|
|
661
|
+
because the criterion is about pointers. The menu was removed on request; resize is now reachable by **dragging**
|
|
662
|
+
the handle (pointer) and by **arrow keys** from the focused handle (keyboard), so **SC 2.5.7 is no longer met for
|
|
663
|
+
column resize**. What would repay it: a single-pointer gesture on the handle itself (a click or double-click
|
|
664
|
+
that steps, or fits the column to its content), or a resize command in a consumer-mounted surface — the column
|
|
665
|
+
registry panel is the natural home, and it already hosts the non-dragging pointer path for column REORDERING.
|
|
666
|
+
The absence is measured by `NienteMenuDiColonna` in `app-data-grid-resize-pinning.stories.tsx`, which also
|
|
667
|
+
declares the debt so it cannot be lost with the feature.
|
|
668
|
+
|
|
669
|
+
⚠️ **The handle's pointer target is 8px wide, below the 24×24 of WCAG 2.2 SC 2.5.8.** Full 24px was not
|
|
670
|
+
reachable while a 24px circle centred on the handle intersected the column menu trigger in the same header;
|
|
671
|
+
that trigger is now gone, so the constraint that kept the target at 8px is gone with it — widening it is
|
|
672
|
+
possible and has not been done, because nothing in this change asked for it. Until then the target stays 8px
|
|
673
|
+
and, with the menu items gone, SC 2.5.8 is **no longer met from the other side either**. Declared, not silently
|
|
674
|
+
accepted.
|
|
675
|
+
|
|
676
|
+
⚠️ **The `<th>` carries an explicit `aria-label`.** Without it the handle, rendered _inside_ the header,
|
|
677
|
+
becomes part of the header's accessible name, because a `columnheader` is named from its content: measured in
|
|
678
|
+
Chrome, the name was `"Sort: Città Sorting: Città 200"` — the column's **width in pixels** was part of its
|
|
679
|
+
name, and changed on every resize. A test asserting the header's accessible name should expect the plain column
|
|
680
|
+
label.
|
|
681
|
+
|
|
682
|
+
⚠️ **The focused handle lifts to `z-40` and fills with `d-foreground`.** Both are measured requirements, not
|
|
683
|
+
styling. Without the lift, a focused handle scrolled behind the pinned block is entirely obscured (WCAG 2.4.11)
|
|
684
|
+
— measured: at `scrollLeft: 200` the topmost element at the handle's own centre was the pinned column's handle.
|
|
685
|
+
Without the fill, the only indicator was a `d-ring/50` outline on a transparent element, measured at
|
|
686
|
+
**1.10:1** against the background versus the 3:1 of WCAG 1.4.11. `d-primary` measured **translucent** in the
|
|
687
|
+
default theme (1.89:1) and is used for hover only; `d-foreground` measures **12.46:1**.
|
|
688
|
+
|
|
689
|
+
⚠️ **Pinned cells follow the row's hover, not its `rowClassName`.** A pinned cell's background cannot be
|
|
690
|
+
transparent — columns would show through during horizontal scroll — so it re-states the row's background
|
|
691
|
+
through a `group-hover` utility on the row. A `bg-*` arriving through `rowClassName` is **not** replicated, so
|
|
692
|
+
a row coloured that way shows its colour only in the non-pinned columns. **Selection is the exception**: since
|
|
693
|
+
R19 the selected row's tint is an inline background on **every** cell, so it does cross the pinning boundary —
|
|
694
|
+
see *Selected row: tint and left bar (R19)*.
|
|
695
|
+
|
|
696
|
+
⚠️ **Measuring "the pinned column is correct at first paint" needs the computed `left`, not the screen
|
|
697
|
+
position.** At `scrollLeft: 0` a `position: sticky` cell sits at its natural position **whatever** its `left`
|
|
698
|
+
is — the value only takes effect once scrolled. A probe reading `getBoundingClientRect().left` at rest passes
|
|
699
|
+
even with no pinning at all; it was measured passing against the post-paint defect this criterion exists to
|
|
700
|
+
forbid. Read `getComputedStyle(cell).left`.
|
|
701
|
+
|
|
702
|
+
### Multi-level headers (P5)
|
|
703
|
+
|
|
704
|
+
Declared in the column tree, not through a prop. `Σ th.colSpan === leafCount` holds on **every** header row by
|
|
705
|
+
construction: each node contributes a `colSpan` equal to the number of leaves under it, and a leaf that starts
|
|
706
|
+
above the last row leaves a **placeholder** (`colSpan` 1, empty) in each row it skips.
|
|
707
|
+
|
|
708
|
+
| Attribute | Where | Value |
|
|
709
|
+
| --------- | -------------------------- | ---------------------------------------------------------------------------- |
|
|
710
|
+
| `id` | every header `<th>` | `<useId prefix>-h-<columnId>`; unique per grid instance |
|
|
711
|
+
| `headers` | every body `<td>` | The ids of the headers covering it, outermost first — derived, never written |
|
|
712
|
+
| `scope` | leaf `<th>` / group `<th>` | `"col"` / `"colgroup"` |
|
|
713
|
+
| `colSpan` | group `<th>` | The number of leaves the group covers |
|
|
714
|
+
|
|
715
|
+
Measured invariants (see `docs/analysis/datatable-v2/VERDETTO-P5.md`):
|
|
716
|
+
|
|
717
|
+
- **Zero `rowSpan`, anywhere.** A `rowSpan` in a header desynchronises the `headers` derivation for every row
|
|
718
|
+
below it, which is the most direct way to break the chain this phase exists to build. Leaves and the select
|
|
719
|
+
column live in the **last** header row, with placeholders above.
|
|
720
|
+
- **Leaf `<th>`s of the last row = `colgroup col` = `<td>`s of the first data row**, in count, with and without
|
|
721
|
+
`checkable`. All three come from the same list.
|
|
722
|
+
- **The header rows stack, and stay stacked.** Measured in Chrome on 10.000 rows with virtualization and pinning
|
|
723
|
+
active: the offsets of the three rows are `0|32|64` — **one single value across 31 scroll frames**. Stickiness
|
|
724
|
+
is on the `<thead>` as a block, not per row: three rows each declaring `top: 0` would overlap, and a test
|
|
725
|
+
asserting the CSS `top` property would pass while showing exactly that.
|
|
726
|
+
- **A group is pinned only if ALL its leaves are, to the SAME side.** Half a group pinned would glue it to the
|
|
727
|
+
edge and drag along columns nobody asked to freeze, covering the data scrolling underneath. Its offset is the
|
|
728
|
+
leaf nearest the edge it anchors to: the first leaf on the left, the last on the right.
|
|
729
|
+
|
|
730
|
+
⚠️ **Pin whole groups, or leaves no group covers.** A group is pinned only when **all** its leaves are, so
|
|
731
|
+
pinning one leaf inside a group leaves that group's header free to scroll — and once you scroll past the group's
|
|
732
|
+
width, the header of a _different_ group slides above the frozen column. Rendering that correctly would mean
|
|
733
|
+
splitting a group's header cell into a pinned part and a free part, which no criterion of this phase asks for.
|
|
734
|
+
Measured and declared, not silently accepted.
|
|
735
|
+
|
|
736
|
+
⚠️ **An invalid column tree throws, on purpose.** Four configurations pass the type-check and would render a
|
|
737
|
+
silently wrong table, so the grid rejects them with a message instead: a group with `columns: []` (it would emit
|
|
738
|
+
`colSpan={0}`, which in HTML means "to the end of the column group"); two nodes sharing an `id` (two `<th>` with
|
|
739
|
+
the same DOM id, so `getElementById` resolves to the first and a column's cells point at another column's
|
|
740
|
+
header); the reserved id `__select`; and an `id` containing whitespace (`headers` is a space-separated list, so
|
|
741
|
+
one id becomes two broken references). The leaf registry already threw on duplicate **leaf** ids; groups never
|
|
742
|
+
enter the registry, which is why these needed their own guard.
|
|
743
|
+
|
|
744
|
+
⚠️ **Two grids on one page do not share header ids.** The id prefix comes from `useId()`, per instance. With a
|
|
745
|
+
module-level prefix `document.getElementById` would still resolve — to the **first** match — so the second
|
|
746
|
+
grid's `headers` would point at the first grid's headers: no error, no orphan id, and a screen reader announcing
|
|
747
|
+
the other table's columns.
|
|
748
|
+
|
|
749
|
+
⚠️ **`headers` is set on every body cell, including in a grid with no groups**, where a single-id chain says what
|
|
750
|
+
`scope="col"` already implies. It is the explicit form of the same fact, and unconditional means one code path
|
|
751
|
+
instead of two.
|
|
752
|
+
|
|
753
|
+
### Virtualization (P3)
|
|
754
|
+
|
|
755
|
+
Opt-in via `virtualized`. When it is on:
|
|
756
|
+
|
|
757
|
+
| Attribute | Where | Value |
|
|
758
|
+
| --------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
759
|
+
| `aria-rowcount` | `[data-grid-table]` | The total **traversable body row** count, not the number of rendered rows — the **known scope** (`resultTotal` / `serverConfig.totalItems`, or derived from the grid's own pagination — `adg-m2`) when there is one (Fase 4), else `10000` with no grouping, data **plus group rows** with `groupBy` (P9), and `-1` while an infinite source still has more (P8) |
|
|
760
|
+
| `aria-rowindex` | `[data-grid-row]` and `[data-grid-group-row]` | The row's **absolute** 1-based position in the traversable sequence, not its position in the DOM — and with `groupBy` that sequence includes the group rows |
|
|
761
|
+
| `style.height` | `[data-grid-spacer]` | The pixels the window's top/bottom must absorb |
|
|
762
|
+
|
|
763
|
+
Measured invariants a test can rely on (all verified in Chrome, see `docs/analysis/datatable-v2/VERDETTO-P3.md`):
|
|
764
|
+
|
|
765
|
+
- `Σ [data-grid-row] heights + both spacer heights === rowCount × rowHeight`, **exactly** — the difference is
|
|
766
|
+
`0`, not "within a row". Row height is `32 / 36 / 48` by density.
|
|
767
|
+
- `[data-grid-viewport].scrollHeight` is **constant** while scrolling. This, not the scrollbar thumb, is the
|
|
768
|
+
measure to assert: at 10.000 rows the real thumb sits at the browser's **minimum** size, so its constancy
|
|
769
|
+
is partly the clamp rather than the property under test.
|
|
770
|
+
- Fewer than 60 `tr` live in the `<tbody>`; the count observed is 22 at rest and at most 31 mid-scroll.
|
|
771
|
+
- No `ResizeObserver` measures the **rows**: `estimateSize` is the exact row height, so nothing measures
|
|
772
|
+
what it also sizes. Reintroducing dynamic measurement of rows is what would bring the
|
|
773
|
+
`ResizeObserver loop completed with undelivered notifications` error back.
|
|
774
|
+
⚠️ **RESTRICTED TO THE ROWS on 2026-09-09, because the wider claim was false and this list is the one a
|
|
775
|
+
test is told it can rely on.** The previous wording said «there is no `ResizeObserver` anywhere in the
|
|
776
|
+
grid». **FOUR exist from `adg-m3`** — three before it — and they are not virtualization:
|
|
777
|
+
`parts/grid-cell-text.tsx` (one per non-empty text cell, to decide whether to emit the tab stop),
|
|
778
|
+
`parts/grid-filter-row.tsx`, `engine/use-grid-infinite-loader.ts`, and — new — **ONE PER GRID in
|
|
779
|
+
`parts/grid-table.tsx`, which observes `[data-grid-viewport]`** to feed the column-width distribution.
|
|
780
|
+
Measured, not deduced: **15 constructions for 6 text cells** on a 3×2 grid of text columns — i.e. at least
|
|
781
|
+
one per rendered cell, and no loop, because the two shapes of `GridCellText` share the MEASURED classes and
|
|
782
|
+
the cycle closes on the first pass. ⚠️ **That 15 is the pre-`adg-m3` figure**: the fourth observer adds one
|
|
783
|
+
per grid MOUNTED, so the same scene now constructs 16 — arithmetic on the new `useLayoutEffect`, not a
|
|
784
|
+
re-measurement. The spec is unaffected either way: it asserts `delta >= cells`, a relation, not the total.
|
|
785
|
+
The measurement lives in `app-data-grid-fix-minori.stories.tsx :: GliOsservatoriDelCorpoSiContano`. A spec
|
|
786
|
+
that asserted «zero observers» was asserting something the component never did.
|
|
787
|
+
⚠️ **The fourth one is the only one that observes a node OUTSIDE the table subtree**, and it reaches it
|
|
788
|
+
with `closest("[data-grid-viewport]")` from the `<table>`, because `GridScrollArea` forwards its ref to the
|
|
789
|
+
VIEWPORT by contract — its `forwardRef` lands on `ScrollAreaPrimitive.Viewport`, the node the scrolling
|
|
790
|
+
specs write `scrollTop` on — and its ROOT has no ref at all, so reaching the root would mean changing that
|
|
791
|
+
file. The viewport is also the right node on the merits: its CONTENT box is the box the table's `w-full`
|
|
792
|
+
resolves against. **It cannot
|
|
793
|
+
loop:** what the distribution resizes is the `<table>`, which is INSIDE the observed node, and the
|
|
794
|
+
viewport's width depends on the grid root rather than on its content — Radix's two bars are `absolute`, so
|
|
795
|
+
`clientWidth` does not move when the overflow appears or disappears. Its declared cost is one extra commit
|
|
796
|
+
of the TABLE subtree at mount (`data-grid-table-commits` starts at **2**, not 1) and **none on the root**:
|
|
797
|
+
the state is local to `GridTable` precisely because `data-render-count` is asserted exactly `"1"` at the
|
|
798
|
+
first commit.
|
|
799
|
+
|
|
800
|
+
⚠️ `aria-rowcount` counts the **traversable body rows** — never the header rows, deliberately deviating from
|
|
801
|
+
ARIA's letter (which counts them too). The reason is that the total must stay the number the user can actually
|
|
802
|
+
walk with the arrow keys, and must not change when P5 adds header levels. A test asserting `10001` is asserting
|
|
803
|
+
the spec, not this component.
|
|
804
|
+
|
|
805
|
+
With `groupBy` on (P9) the traversable sequence **includes the group rows**, so the total is data rows plus the
|
|
806
|
+
group rows currently on screen, and closing a group lowers it — the rows inside a collapsed group are not
|
|
807
|
+
traversable, so they are not counted. `aria-rowindex` follows the same sequence and is carried by
|
|
808
|
+
`[data-grid-group-row]` as well as `[data-grid-row]`. A test asserting `aria-rowcount === <number of data rows>`
|
|
809
|
+
on a grouped grid is asserting something this component deliberately does not promise.
|
|
810
|
+
|
|
811
|
+
⚠️ **Three kinds of row live outside the `aria-rowindex` space, and only one of them is exposed.** Header rows
|
|
812
|
+
are outside the count by the deviation above; the **skeleton** rows carry `aria-hidden="true"`, so they are
|
|
813
|
+
outside the a11y tree entirely; the **totals row** of the `<tfoot>` is inside the tree with **no
|
|
814
|
+
`aria-rowindex`** — deliberately, because it is not a data row (any index would place it in the data space it
|
|
815
|
+
does not belong to) and `aria-hidden` would hide the total from exactly the people who need to hear it. The
|
|
816
|
+
**status row** (`[data-grid-status-row]`) is _not_ in this list: it carries `role="presentation"`, so the table
|
|
817
|
+
exposes **zero** body rows in the empty/error outcomes — matching the `aria-rowcount` of `0` it declares there.
|
|
818
|
+
Per ARIA's presentational-role conflict resolution the role propagates to the row's owned cells, so the `<td>`
|
|
819
|
+
itself carries **no `role`** — which is what the empty-state contract above requires. Its content, message and
|
|
820
|
+
«Retry» button included, stays in the tree; what stops existing is the ROW.
|
|
821
|
+
|
|
822
|
+
⚠️ **Scrolling never emits.** `data-emissions` is unchanged by any amount of scrolling — virtualization is a
|
|
823
|
+
view concern and cannot reach `onQueryChange`. A filter typed while scrolled deep resets the window and the
|
|
824
|
+
scroll position and emits exactly once.
|
|
825
|
+
|
|
826
|
+
⚠️ **A zero-height viewport does not mean "render everything".** If the scroll container measures 0px —
|
|
827
|
+
`viewportHeight={0}`, a still-collapsed parent, a closed panel — the virtualizer cannot compute a range. The
|
|
828
|
+
grid renders a **minimal window** (a couple of dozen rows) rather than the full dataset: measured before the
|
|
829
|
+
fix, that path emitted 10.000 `tr` and 50.180 DOM nodes, i.e. the exact cost `virtualized` exists to avoid,
|
|
830
|
+
paid _because_ it was requested.
|
|
831
|
+
|
|
832
|
+
⚠️ **Opening a big-dataset story: use the manager, not just the iframe.** `iframe.html?id=…` has no addon
|
|
833
|
+
panel, so it cannot show a whole class of defect. Measured on a 10.000-row story: the manager page held
|
|
834
|
+
**341.828** DOM nodes (340.442 of them `<span>` from the dynamic source snippet) while the preview iframe
|
|
835
|
+
held **291** and the grid was virtualizing correctly. Any story shipping a large dataset should set
|
|
836
|
+
`parameters.docs.source.type = "code"`, or the "Show code" snippet serializes the whole dataset into
|
|
837
|
+
syntax-highlighted spans.
|
|
838
|
+
|
|
839
|
+
⚠️ **Virtualization stays ON while `loading` — this was inverted in P8.** It used to be off, for the
|
|
840
|
+
reason written at the time: skeleton rows are not data rows, so spacers sized on `data.length` put 10
|
|
841
|
+
skeletons above a 359,208px void. That was a remedy, not the cause. From P8 the virtualizer **counts
|
|
842
|
+
what is rendered** — data rows plus skeleton rows — so the spacers are exact in both states and the
|
|
843
|
+
void is unrepresentable rather than avoided. See «Infinite source and skeleton rows (P8)».
|
|
844
|
+
|
|
845
|
+
⚠️ **Changing density re-measures.** `estimateSize` is not a memo dependency inside react-virtual, so the grid explicitly calls `virtualizer.measure()` when the row height changes. Without it, switching density left the declared height frozen at the mount-time density — measured at 119,736px of error, with the content jittering 12px at every row boundary. A test that changes density must re-assert the height identity, and one that changes it only at `scrollTop` 0 hides half the error.
|
|
846
|
+
|
|
847
|
+
⚠️ A pinned cell is only _visibly_ pinned when the columns overflow horizontally. Without overflow it still
|
|
848
|
+
carries `data-grid-pinned="true"`, which is what makes it measurable — but a test that wants to prove pinning
|
|
849
|
+
must force the overflow (declare widths whose sum exceeds the container) or it proves nothing.
|
|
850
|
+
|
|
851
|
+
⚠️ The first `<tr>` in the `<tbody>` is the **top spacer**, so `[data-grid-row]:first-of-type` matches nothing. Use `querySelector("[data-grid-row]")` for the first data row.
|
|
852
|
+
|
|
853
|
+
⚠️ Sort **priority** is not readable from the headers — they render in column-declaration order regardless of it. Read `computedSorting.sort_by` from the emitted payload instead; a test asserting priority against `aria-sort` order passes even when priority is wrong.
|
|
854
|
+
|
|
855
|
+
### Density is the consumer's setting, not a toolbar control (`adg-c12`) — BREAKING
|
|
856
|
+
|
|
857
|
+
The grid **no longer renders a density switcher**, and `toolbar.density` **no longer exists** as a key.
|
|
858
|
+
Row height is set by the `density` prop, default `"default"` (standard, 36px) — unchanged. This retracts
|
|
859
|
+
the P6 rule for that one key; `search` and `fullscreen` are still rendered always, and decision 26 still
|
|
860
|
+
holds for them.
|
|
861
|
+
|
|
862
|
+
**Why.** Density is view configuration, and whoever mounts the table is who knows whether its rows should
|
|
863
|
+
be tight or roomy. The retired argument was «a table whose density changes only if the call-site
|
|
864
|
+
remembered a boolean is a table whose density, in practice, never changes» — a rule about the end user's
|
|
865
|
+
experience, imposed on every call-site of the library.
|
|
866
|
+
|
|
867
|
+
**How to migrate.** If you want to offer the switch to the end user, mount the control yourself — it is
|
|
868
|
+
now a public export, so you get the real `role="group"`, the i18n labels and the `sr-only` full words
|
|
869
|
+
instead of three hand-rolled buttons:
|
|
870
|
+
|
|
871
|
+
```tsx
|
|
872
|
+
import { AppDataGrid, GridDensityControl, type GridDensity } from "@laif/ds";
|
|
873
|
+
|
|
874
|
+
const [density, setDensity] = useState<GridDensity>("default");
|
|
875
|
+
|
|
876
|
+
<AppDataGrid
|
|
877
|
+
columns={columns}
|
|
878
|
+
data={data}
|
|
879
|
+
density={density}
|
|
880
|
+
// in the toolbar, where it used to be…
|
|
881
|
+
toolbar={{ actions: { render: () => (
|
|
882
|
+
<GridDensityControl density={density} onChange={setDensity} />
|
|
883
|
+
) } }}
|
|
884
|
+
/>;
|
|
885
|
+
// …or anywhere outside the table: `AppDataGrid/Completa` puts it above the grid.
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
⚠️ **If you pass a fixed `viewportHeight`, derive it from the LIVE density.**
|
|
889
|
+
`gridViewportHeight` multiplies `gridRowHeight[density]` by the row count, so a constant `"default"`
|
|
890
|
+
beside rows rendered at another height promises ten rows and fits eight — the `<tfoot>` then covers rows
|
|
891
|
+
the paginator announces. While the control lived inside the grid this could not be got wrong.
|
|
892
|
+
|
|
893
|
+
⚠️ **`GridDensityControl` needs no grid in the tree.** It is a standalone control, like
|
|
894
|
+
`GridFilterChips` and `GridFilterPanel`.
|
|
895
|
+
|
|
896
|
+
### Density, full screen and vertical space (P6)
|
|
897
|
+
|
|
898
|
+
Density is a real control, not the provisional switch P2 left behind: `showDensityControl` **is gone**, and
|
|
899
|
+
the three-button group (`S` / `M` / `L`) plus the full-screen toggle are always rendered in the toolbar. The
|
|
900
|
+
row height still comes from `gridRowHeight` in `grid-tokens.ts` — `32 / 36 / 48`, exact and not estimated.
|
|
901
|
+
|
|
902
|
+
**Full screen is a CSS promotion of the same DOM node.** The root gains `fixed inset-0` and the
|
|
903
|
+
`designTokens.tableZ.fullscreen` z-index; nothing is unmounted, nothing is portaled, and
|
|
904
|
+
`requestFullscreen` is **not** called. Three consequences, each with a spec:
|
|
905
|
+
|
|
906
|
+
- **It cannot remount** — `data-mount-count` is unchanged across two enter/leave cycles. Remounting would
|
|
907
|
+
recreate the engine, so it would refetch and lose scroll position, selection and sort.
|
|
908
|
+
- **It cannot refetch** — `view/fullscreen` is a view action with no path to the emitter, so
|
|
909
|
+
`data-emissions` is unchanged. The property is unrepresentable, not merely untested.
|
|
910
|
+
- **Scroll position survives**, because the scrolling element is literally the same node. Measured on
|
|
911
|
+
`aria-rowindex` of the first rendered row, not on `scrollTop`, which changes legitimately when the
|
|
912
|
+
viewport grows.
|
|
913
|
+
|
|
914
|
+
⚠️ **Why not `requestFullscreen`.** A natively fullscreen element enters the browser's **top layer**, which
|
|
915
|
+
paints above any `z-index` — so "the grid covers the old table's overlays" would pass with any number in the
|
|
916
|
+
scale, and with no scale at all. That spec would be **vacuous**. Two lesser reasons point the same way: the
|
|
917
|
+
API requires a real user gesture and rejects on the synthetic clicks a spec dispatches, and `Esc` exits
|
|
918
|
+
without going through React, so the reducer and the browser would disagree with nothing to detect it.
|
|
919
|
+
|
|
920
|
+
⚠️ **The z-index is 110, and it was measured, not chosen.** The grid must cover overlays the old `DataTable`
|
|
921
|
+
portals to the body, and those carry the z-index of the shared primitive they are built from — not
|
|
922
|
+
`tableZ`. Measured across `src/components`: the ceiling is `z-[100]` (the frozen table's own horizontal
|
|
923
|
+
`ScrollBar`), and `popover.tsx` is at `z-[60]`. **60 was the first choice and it was wrong**: it ties with
|
|
924
|
+
`popover.tsx`, and a tie is broken by document order — a Radix popover is appended to the end of `<body>`,
|
|
925
|
+
so the popover would have won. The spec catches exactly this.
|
|
926
|
+
|
|
927
|
+
⚠️ **The promotion loses to an ancestor stacking context, and no number fixes it.** A `position: fixed`
|
|
928
|
+
element only competes with a body-level portal when no ancestor creates a stacking context. Probed in Chrome
|
|
929
|
+
with `document.elementFromPoint`: with a plain ancestor the fullscreen grid wins; give that ancestor
|
|
930
|
+
`transform: translateZ(0)` or `filter: blur(0px)` and the **portal** wins, because the grid's z-index is then
|
|
931
|
+
scoped inside the ancestor's context. If a host app wraps the grid in a transformed or filtered container,
|
|
932
|
+
full screen will render below that app's own overlays. This is a containing-block fact, not a bug to tune.
|
|
933
|
+
|
|
934
|
+
⚠️ **The sticky header's separation is a `box-shadow`, not a border.** Under `border-collapse: collapse` a
|
|
935
|
+
border — on `<th>` or on `<tr>` alike — is collapsed into the **table's** border grid, so it belongs to the
|
|
936
|
+
table's flow and not to the sticky `<thead>` box: scrolling leaves the header in place and takes the line
|
|
937
|
+
away with the body. Measured in Chrome at `scrollTop: 200`, two tables side by side: with the shadow on the
|
|
938
|
+
`<thead>` the line is painted across the full table width; with `border-bottom` on the `<tr>` it is **not
|
|
939
|
+
painted at all**. This closes the observation P3 left open. The **last** header row therefore carries no
|
|
940
|
+
border: keeping both would give a 2px line at scroll 0 and a 1px line once scrolled.
|
|
941
|
+
|
|
942
|
+
⚠️ **And since 2026-09-09 the same holds for the lines BETWEEN header rows — a collapsed border there did
|
|
943
|
+
not merely vanish, it punched a hole.** The intermediate rows used to carry `border-b` on the `<tr>`. That
|
|
944
|
+
border is collapsed into the table's border grid, so it scrolls away with the body, and the 1px strip it
|
|
945
|
+
occupied is covered by **nothing**: not the row above, not the row below, and not the `<thead>`'s own
|
|
946
|
+
background. Scrolling therefore showed **one pixel of the body** passing between the header rows, across the
|
|
947
|
+
full width. Measured in Chrome, `devicePixelRatio` 2, `scrollTop: 137.5`, body painted `#ff0000`, pixels read
|
|
948
|
+
off the compositor's PNG: 670 red samples out of 888 at each internal boundary and zero inside the rows;
|
|
949
|
+
after the fix, zero everywhere in the header block. A `<thead>` background is not the remedy either — with
|
|
950
|
+
`thead { background: #00ff00 !important }` the green never appears at the boundary, which stays red.
|
|
951
|
+
|
|
952
|
+
The line between two header levels is therefore `box-shadow: inset 0 -1px 0 0 var(--d-border)`, and it lives
|
|
953
|
+
in **two** places on purpose: on the `<tr>`, which is the only element spanning the full table width
|
|
954
|
+
including the phantom column, and — composed through `gridPinnedOffsetStyle` — on the **opaque** cells of
|
|
955
|
+
those rows, because a pinned cell's `bg-d-background` paints over the row's shadow (measured: with the row
|
|
956
|
+
shadow alone the line ran from x 73 instead of x 25). It also closes a question this repo had left open in
|
|
957
|
+
writing: a `box-shadow` on a `<tr>` under `border-collapse` **is** painted.
|
|
958
|
+
|
|
959
|
+
⚠️ **The pinned block's edge and the header's group line are ONE `box-shadow`, and both are `inset`.**
|
|
960
|
+
The paragraph above settled the VERTICAL axis. R20 measured the HORIZONTAL one, which had never been
|
|
961
|
+
measured in this repo.
|
|
962
|
+
|
|
963
|
+
**Instrument.** Presence *and* absence are read as **pixels off the compositor's PNG**, not eyeballed and
|
|
964
|
+
not inferred: a screenshot is saved to disk, the scale is derived from the file width over
|
|
965
|
+
`window.innerWidth` (2400/1200 = 2.0, `devicePixelRatio` 2), and a **horizontal scan of x ∈ [250, 500]** at
|
|
966
|
+
the row's mid-height reports the exact interval where a saturated probe colour appears — or reports that it
|
|
967
|
+
appears nowhere. `elementFromPoint` is *not* the instrument here: it would prove stacking, not painting.
|
|
968
|
+
Scene: the `Per il gesto a mano` story, wrapper narrowed to 520px, `scrollLeft = 120`, boundary cell's right
|
|
969
|
+
edge at **x = 473**. One factor per row; each probe forced past the shipped inline style with `!important`,
|
|
970
|
+
which the first pass had missed.
|
|
971
|
+
|
|
972
|
+
| Factor (on the last left-pinned cell) | collapsed borders (the grid's regime) | separated borders (control) |
|
|
973
|
+
| --- | --- | --- |
|
|
974
|
+
| vertical `border-right: 6px` | painted at **x=[350..355]** — i.e. `473 − 120`, the table's grid position | painted at **x=[467..472]** — back on the cell |
|
|
975
|
+
| **non-`inset`** `box-shadow` | **painted nowhere in the scan** | painted at **x=[473..478]** |
|
|
976
|
+
| **`inset`** `box-shadow` | painted at **x=[467..472]** | painted at **x=[467..472]** |
|
|
977
|
+
| `::after`, absolute, `right: 0` | painted at **x=[467..472]** | painted at **x=[467..472]** |
|
|
978
|
+
| `::after`, absolute, `right: -6px` | **painted nowhere** — clipped by the cell's `overflow: hidden` | **painted nowhere** — same clip |
|
|
979
|
+
| `::after`, `right: -6px`, `overflow: visible` | painted at **x=[473..478]** — **casts OUTWARD** | painted at **x=[473..478]** |
|
|
980
|
+
|
|
981
|
+
Exactly one factor changes between the two columns, and only two rows move: the border returns to the cell,
|
|
982
|
+
and the non-`inset` shadow appears. So this is not "sticky breaks painting" — it is the **collapsed-border
|
|
983
|
+
regime** deciding, and that is the regime this grid lives in. It is also why the mockup's classes are **not
|
|
984
|
+
portable**: the mockup renders its table with separated borders.
|
|
985
|
+
|
|
986
|
+
**What the measurement supports, stated no wider than that.** Among the **`box-shadow` declarations on the
|
|
987
|
+
cell**, the `inset` one is the only one painted in this regime — that is what is measured, and it is why the
|
|
988
|
+
shipped form is `inset`. It is **not** true that `inset box-shadow` is the only mechanism available: an
|
|
989
|
+
absolutely-positioned **pseudo-element is a second mechanism**, it is regime-independent (identical in both
|
|
990
|
+
columns above), it follows the sticky cell, and with `overflow: visible` it **casts outward over the
|
|
991
|
+
scrolling content** — which is what the mockup does. Where that goes next is **deviation row 38**, below.
|
|
992
|
+
|
|
993
|
+
⚠️ **Declared deviation from the mockup — this is deviation row 38, and it is OPEN.** The shipped edge
|
|
994
|
+
**fades inward**, inside the boundary cell; the mockup's shadow **throws outward**, over the content that
|
|
995
|
+
scrolls under it. The task's human check reads "the locked block casts a shadow", so today it is satisfied
|
|
996
|
+
in a **weakened sense**. The pseudo-element route reproduces the mockup, and its price is named:
|
|
997
|
+
`overflow: hidden` on the boundary cell has to be lifted, and that is exactly what the text-clipping
|
|
998
|
+
utility sets — so the column's ellipsis would have to move to an inner element.
|
|
999
|
+
|
|
1000
|
+
**The decision is recorded, not pending in the dark: Lorenzo, 2026-09-04 — the shipped form stays `inset`,
|
|
1001
|
+
and the pseudo-element becomes a task of its own.** The follow-up lives in **row 38 of the deviations table
|
|
1002
|
+
in `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md`**, which carries the six-row measurement, both
|
|
1003
|
+
evidence PNGs, the pseudo-element's price and the one thing still unmeasured (how an outward-throwing
|
|
1004
|
+
pseudo-element behaves against the stacking of the neighbouring pinned cells and the `<thead>`). Read that
|
|
1005
|
+
row before changing anything here: the difference is known and tracked, not forgotten.
|
|
1006
|
+
|
|
1007
|
+
The three tokens live at the tail of `grid-tokens.ts`:
|
|
1008
|
+
|
|
1009
|
+
| Token | Value | Where |
|
|
1010
|
+
| --- | --- | --- |
|
|
1011
|
+
| `gridPinnedLeftEdgeShadow` | a **list of two layers**: `inset -1px 0 0 0 var(--d-border)` (the crisp hair) and `inset -6px 0 6px -6px var(--d-border)` (the blur) | inner RIGHT edge of the **last** left-pinned cell |
|
|
1012
|
+
| `gridPinnedRightEdgeShadow` | the left list **mapped** through one mirroring expression, so every layer is flipped — never recopied | inner LEFT edge of the **first** right-pinned cell |
|
|
1013
|
+
| `gridHeaderGroupShadow` | `inset 1px 0 0 0 var(--d-border)` | inner LEFT edge of every group-label header cell |
|
|
1014
|
+
|
|
1015
|
+
⚠️ **The pinned edge is TWO layers, and each one has a job. The blurred one gives the depth; the crisp one
|
|
1016
|
+
gives the separation.** Measured, not chosen by eye: with the blurred layer alone the edge stood off its
|
|
1017
|
+
background by **10 levels out of 255** (245 → 235), while the mockup — measured with the same instrument —
|
|
1018
|
+
stands off by **59** (250 → 191). The cause is *not* the colour: both use the same `--d-border`,
|
|
1019
|
+
`rgb(224,224,224)`. It is that a single layer with blur `6` and spread `-6` never reaches the colour's full
|
|
1020
|
+
strength anywhere, whereas the mockup lays a **full 1px border** down and puts the gradient on top of it.
|
|
1021
|
+
The crisp layer is that border, and it brings the separation to **21** (245 → 224) — measured on the mounted
|
|
1022
|
+
grid, not predicted. **21 is not 59**, and closing that gap needs a colour darker than `--d-border`, which
|
|
1023
|
+
the theme has no token for: the only darker `--d-*` are *text* colours. That is why deviation row 38 stays
|
|
1024
|
+
**open**, and it is Lorenzo's decision of 2026-09-04 to take the 10 → 21 step and stop there.
|
|
1025
|
+
|
|
1026
|
+
⚠️ **The crisp right-hand layer collides with the group line, by construction.** Mirrored, the crisp layer
|
|
1027
|
+
is `inset 1px 0 0 0 var(--d-border)` — the *same string* as `gridHeaderGroupShadow`. On a cell that is both
|
|
1028
|
+
a group label and the right block's edge the two would paint the same pixel twice, so
|
|
1029
|
+
`gridPinnedOffsetStyle` **de-duplicates identical layers**. The two layers are kept as separate list
|
|
1030
|
+
entries rather than one comma-joined string precisely so that de-duplication never has to re-open a string:
|
|
1031
|
+
a `split(", ")` would hold only while every token uses `var(--d-border)`, which has no commas, and would
|
|
1032
|
+
silently cut a future colour function that does have them in half. The consequence for anyone reading computed
|
|
1033
|
+
styles: on a right-boundary cell the geometry no longer says *which* signal drew that line, so a spec
|
|
1034
|
+
counting where the group line lives must exclude right-boundary cells (recognisable by the still-unique
|
|
1035
|
+
blurred right layer) and say so, rather than counting them wrong.
|
|
1036
|
+
|
|
1037
|
+
The group line is a **hairline** and the pinned edge is a hairline **plus** a blur, and the difference is
|
|
1038
|
+
load-bearing: a pinned block *casts a shadow*, a group separator *draws a line*, and on the one cell that is
|
|
1039
|
+
both — a group label that is also the block's LEFT edge — the three layers stay distinguishable. The
|
|
1040
|
+
composition happens in **`gridPinnedOffsetStyle` and nowhere else**: they contend for a single
|
|
1041
|
+
`box-shadow` declaration, so assigned at two call-sites the second would erase the first silently, with a
|
|
1042
|
+
green `npm run check`.
|
|
1043
|
+
|
|
1044
|
+
The boundary predicate itself — "am I the last on the left, the first on the right?" — lives once, in
|
|
1045
|
+
`gridPinnedSlots`, and travels inside `GridPinnedSlot.edge`. **`gridPinnedOffsetStyle` has seven
|
|
1046
|
+
call-sites**, and all seven paint the edge: **two in the header** (one shared by the placeholder and the
|
|
1047
|
+
leaf, one for the group cell that also asks for the group line), the **data row**, the **group row**, the
|
|
1048
|
+
**filter row**, the **skeleton row** and the **`<tfoot>`**. The filter row and the skeleton row are the two
|
|
1049
|
+
the first pass shipped without a probe; both are deliberate and now asserted in both directions — the
|
|
1050
|
+
filter row sits in the same sticky band as the header and the skeleton row occupies a data row's place, so
|
|
1051
|
+
an edge that stopped at either would read as a broken edge rather than a body-only one. A group header cell takes its
|
|
1052
|
+
**anchor** from the leaf nearest its edge and its **boundary** from the leaf at the other end
|
|
1053
|
+
(`gridPinnedGroupSlot`), because a left-pinned group is anchored by its first leaf and borders the scrolling
|
|
1054
|
+
content with its last.
|
|
1055
|
+
|
|
1056
|
+
⚠️ **Declared limit — "the checkbox column alone pinned" is not representable, and could not be painted
|
|
1057
|
+
either.** Two independent halves, and both matter:
|
|
1058
|
+
|
|
1059
|
+
- **No input reaches that state.** The select column's pinning is derived, not independent:
|
|
1060
|
+
`pinning = pinnedLeftIds.length > 0` in `parts/grid-table.tsx`. Hide the last pinned data column and the
|
|
1061
|
+
checkbox unpins with it rather than staying at the edge alone. Measured live in `IlConfineMigra`: after
|
|
1062
|
+
hiding both pinned columns, zero cells report `data-grid-pinned` and the select cell's computed `position`
|
|
1063
|
+
is `static`.
|
|
1064
|
+
- **No render path could paint it.** The select cell is rendered with an inline `left: 0` and **never calls
|
|
1065
|
+
`gridPinnedOffsetStyle`** (`grid-row.tsx`, `grid-group-row.tsx`, `grid-filter-row.tsx`, and the skeleton's
|
|
1066
|
+
select cell in `grid-table.tsx`), and in the header `pinnedSlotOf` returns `undefined` on `isSelectCell`.
|
|
1067
|
+
So even if such a state existed, the checkbox has no code path that reads the edge token.
|
|
1068
|
+
|
|
1069
|
+
Together these close the question instead of leaving it open: it is not "not yet wired", it is "not wired,
|
|
1070
|
+
by two separate facts".
|
|
1071
|
+
|
|
1072
|
+
⚠️ **The group line is on the LEFT inner edge, not the right.** On the right it would collide with
|
|
1073
|
+
`gridPinnedLeftEdgeShadow`, which is exactly the layer it has to compose with on the left block's boundary
|
|
1074
|
+
cell — two layers on the same side of the same cell are an overlap, not a composition.
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
⚠️ **The density buttons carry no `aria-label`, on purpose.** Visible text is `S` / `M` / `L`; the descriptive
|
|
1078
|
+
part is appended in an `sr-only` span, so the accessible name is `S Compact rows` — it **contains** the visible
|
|
1079
|
+
text. An `aria-label` would replace it instead, which is a **WCAG 2.5.3 Label in Name (A)** failure: measured
|
|
1080
|
+
in Chrome's a11y tree, the name read `Compact rows` while the button showed `S`, so a voice-control user
|
|
1081
|
+
saying "click S" could not activate it. A spec asserts the containment relation (not the full string, which
|
|
1082
|
+
would be language-dependent) and asserts the absence of `aria-label`, since an `aria-label` would win over
|
|
1083
|
+
the content and make the measurement meaningless.
|
|
1084
|
+
|
|
1085
|
+
⚠️ **`data-mount-count` needs a stable `id`.** The count lives in `grid-mount-count.ts`, outside the
|
|
1086
|
+
component's lifecycle, because a `useRef` cannot survive the remount it is supposed to detect — it would
|
|
1087
|
+
report `1` both for a correct in-place promotion and for the remount the criterion exists to exclude. The
|
|
1088
|
+
key is the call-site's `id` (then `data-testid`); **with neither, the counter is per-instance and always
|
|
1089
|
+
`1`**, i.e. vacuous. Pass an `id` when you intend to measure it.
|
|
1090
|
+
|
|
1091
|
+
Under `StrictMode` the value stays **`1`**, not `2`: React's simulated double-mount reuses the same component
|
|
1092
|
+
instance, so the caller's `mountCount.current` is no longer `null` and its guard collapses the second
|
|
1093
|
+
registration. A **real** remount — new instance, new ref — counts `2`, which is the difference the criterion
|
|
1094
|
+
has to see. (An earlier version of this note claimed the opposite; corrected after a review read the caller.)
|
|
1095
|
+
|
|
1096
|
+
⚠️ **Full screen declares itself modal, and `Escape` exits.** While full screen the root carries
|
|
1097
|
+
`role="dialog"`, `aria-modal="true"` and an `aria-label`, and a keydown of `Escape` on the grid leaves full
|
|
1098
|
+
screen (through `applyView`, so it still cannot emit). Without this, tabbing moved focus onto content the
|
|
1099
|
+
grid covers completely — visible to no one, still activatable — which is a **WCAG 2.4.11 Focus Not Obscured
|
|
1100
|
+
(AA)** failure, and the only way out was to hit a 24px button with a pointer.
|
|
1101
|
+
|
|
1102
|
+
**Declared limit:** there is **no `inert` on the ancestors' siblings and no focus trap.** `aria-modal` removes
|
|
1103
|
+
the content underneath from the screen-reader tree but _not_ from the tab order; doing that properly means
|
|
1104
|
+
writing to nodes the grid does not own (its ancestors' siblings in the host app), which this component
|
|
1105
|
+
deliberately does not do. A host that needs a true trap should render the grid inside its own dialog.
|
|
1106
|
+
|
|
1107
|
+
⚠️ **The sticky header lifts its own stacking context while a resize handle has focus, at
|
|
1108
|
+
`tableZ.stickyHeaderFocusLift` (45) — R21.** The scrollbar lane sits at 40 and covered a focused resize
|
|
1109
|
+
handle completely, which fails WCAG 2.2 SC 2.4.11. The obvious fix — raising the handle's own
|
|
1110
|
+
`focus-visible` z — **cannot work, and this was measured, not assumed**: the `<thead>` is `sticky` with a
|
|
1111
|
+
z-index of its own, so it CREATES a stacking context and confines every descendant inside it. The handle's
|
|
1112
|
+
lift already beats the pinned header cells (30, same context) and can never reach a lane that lives outside
|
|
1113
|
+
the table. So the `<thead>` itself lifts, and only while a handle is focused (`focusin`/`focusout`,
|
|
1114
|
+
recognised by the handle's `data-grid-resize-handle` marker); at rest it returns to 20, because a permanently
|
|
1115
|
+
lifted header would change the stacking of every scene.
|
|
1116
|
+
|
|
1117
|
+
⚠️ **45 is between 40 and 50 on purpose, and it is inline from the token rather than a class.** It must beat
|
|
1118
|
+
the scrollbar lane (40) and stay below the filter panel (50), so an overlay still covers the header as it
|
|
1119
|
+
should. There is no Tailwind step at 45, and this lane forbids arbitrary values in classes — `check-grid-css`
|
|
1120
|
+
rejects any token containing a bracket, variants included — so the value is applied inline from the token,
|
|
1121
|
+
the same shape the full-screen branch uses and for the same reason.
|
|
1122
|
+
|
|
1123
|
+
⚠️ **Full screen raises the grid's OWN overlays too, at `tableZ.fullscreenOverlay` (120).** Raising the grid
|
|
1124
|
+
to 110 to cover the old table's body-portaled overlays also raised it above its own portaled menus (a Radix
|
|
1125
|
+
dropdown portaled to `<body>` at z-50). The witness used to be the column menu, whose removal took P4's
|
|
1126
|
+
non-dragging pointer path with it; the property is unchanged and the witness is now the ROW menu, which carries
|
|
1127
|
+
the same `modal={false}` and the same portal — see `MenuDiColonnaSopraLaGrigliaAScehrmoIntero`. It made P4's
|
|
1128
|
+
non-dragging pointer path (WCAG 2.5.7 "widen /
|
|
1129
|
+
narrow") and sorting-from-the-menu unreachable — measured with `document.elementFromPoint` at the open menu's
|
|
1130
|
+
centre: the grid was on top. The menu now takes an inline z from that token while full screen is active.
|
|
1131
|
+
|
|
1132
|
+
The z-index that decides is the one on **Radix's popper wrapper**, not on the menu content: the wrapper is the
|
|
1133
|
+
stacking context. Radix copies the content's computed z-index onto the wrapper at mount, which is why an
|
|
1134
|
+
inline z on the content reaches it — and why raising the content's z _after_ mount does nothing. `DropdownMenuContent`
|
|
1135
|
+
hard-codes its Portal with no `container` passthrough, and that file is shared surface the frozen `DataTable`
|
|
1136
|
+
renders, so this lane raises its own overlay from its own side rather than changing the primitive.
|
|
1137
|
+
|
|
1138
|
+
⚠️ **The density buttons' pressed state inverts fill and text**, rather than using `bg-d-muted`. In the shipped
|
|
1139
|
+
theme `--d-muted` and `--d-background` resolve to the **same** colour, so the pressed fill measured **1.00:1**
|
|
1140
|
+
against the grid background and which density was active was invisible — only a colour difference between two
|
|
1141
|
+
greys remained (**WCAG 1.4.11** wants 3:1 for a state indicator). `--d-foreground` is by definition the colour
|
|
1142
|
+
readable on `--d-background`, so it is the one pair a theme cannot get wrong: measured 12.46:1, the same figure
|
|
1143
|
+
P4 reached for its focus indicator. `font-medium` adds a non-colour signal (**1.4.1**), and every toolbar
|
|
1144
|
+
control is at least 24×24 (**2.5.8**) with an explicit `min-w-6` — relying on glyph width plus padding
|
|
1145
|
+
measured 23px in one runner and 24px in Chrome, and a target that conforms only under some font metrics does
|
|
1146
|
+
not conform.
|
|
1147
|
+
|
|
1148
|
+
### Lifted filters, chips, panel and column registry (P7)
|
|
1149
|
+
|
|
1150
|
+
The filter state lives **above** the grid, in `useGridFilters`. That is what makes the deciding criterion
|
|
1151
|
+
true: **the chips work with no `<AppDataGrid>` in the tree.** If the chips lived inside the grid, putting them
|
|
1152
|
+
at the top of the page — where R2 wants them — would mean rendering two grids or passing props backwards. So
|
|
1153
|
+
the hook owns the state, `GridFilterChips` reads it, `GridFilterPanel` writes it, and the grid receives it as
|
|
1154
|
+
an ordinary prop (`filters`, `columnVisibility`). Both are **controlled when passed**: present means they
|
|
1155
|
+
command, absent means the grid keeps its own slice exactly as before P7.
|
|
1156
|
+
|
|
1157
|
+
It is **not a context**, deliberately: a context would solve the same problem while making the chips usable
|
|
1158
|
+
only inside a provider — reintroducing, in another shape, the coupling this phase exists to break.
|
|
1159
|
+
|
|
1160
|
+
**The panel WAS an in-flow section, and from P7 it is the 288px overlay inventory.** The human signature that
|
|
1161
|
+
had chosen the flow was overturned by a measured criterion — «opening the panel moves no row and takes away no
|
|
1162
|
+
column» — and the three reasons written at the time have to be read for what survives of them:
|
|
1163
|
+
|
|
1164
|
+
- «no focus trap» — **still true, and still the choice.** The body does not trap focus; in full screen the
|
|
1165
|
+
cycle is closed by the two sentinels inside `[data-app-data-grid]`. A trap here would be a second cycle
|
|
1166
|
+
nested inside that one, and tabbing past the last control the two handlers would bounce the focus at each
|
|
1167
|
+
other in the same tick. ⚠️ **Consequence for the call-site: render the panel INSIDE the grid root** (the
|
|
1168
|
+
cheapest way is `toolbar.filters.render`, which needs no new prop). Outside it, in full screen the root's
|
|
1169
|
+
`fixed inset-0` covers the panel and the sentinels exclude it from the tab cycle — invisible AND unreachable
|
|
1170
|
+
by keyboard, with no error anywhere. That is why the inventory spec re-runs the whole measurement in full
|
|
1171
|
+
screen instead of stopping at the first pass;
|
|
1172
|
+
- «not portaled, so it never enters the z-scale» — **no longer true**, and it is the price of the criterion. It
|
|
1173
|
+
is still not portaled (so it stays inside the sentinels' cycle), but it paints over, so it needs a `z`. That
|
|
1174
|
+
`z` comes from `gridZClass.overlay` and never from a literal: the step below is `focusLift`, i.e. the focused
|
|
1175
|
+
resize handle, and at equal z-index document order decides — a panel written on that step would end up
|
|
1176
|
+
underneath a handle that has the focus;
|
|
1177
|
+
- «the body can really be unmounted» — **still true.** **Closed, the body is `null`, not `display:none`** — a
|
|
1178
|
+
hidden but mounted panel keeps its inputs and their handlers alive, i.e. an invisible form that still reacts.
|
|
1179
|
+
|
|
1180
|
+
The width is `w-72` and **never** an arbitrary value: `check-grid-css.mjs` rejects square brackets in this lane,
|
|
1181
|
+
because the v3 and v4 bundles do not resolve them the same way and a class the v3 bundle does not emit is a
|
|
1182
|
+
full-width panel in every consumer still on Tailwind 3 — with `npm run check` green. The measurement that
|
|
1183
|
+
carries the criterion is `getBoundingClientRect().width === 288` on the EFFECT, plus
|
|
1184
|
+
`getComputedStyle(panel).position === "absolute"`, plus the two deltas of exactly 0; never a grep on the class,
|
|
1185
|
+
which says what was asked for and not what was rendered.
|
|
1186
|
+
|
|
1187
|
+
⚠️ **The panel lists the AVAILABLE filters and also keeps the applied rows — declared deviation.** The
|
|
1188
|
+
inventory (`[data-grid-filter-inventory]`) is the answer to the criterion; the rows of the applied filters and
|
|
1189
|
+
the «add» control stay below it, inside the scrolling area. Removing them was measured as costing more than it
|
|
1190
|
+
buys: six assertions of `app-data-grid-filters-lifted.stories.tsx` interrogate
|
|
1191
|
+
`[data-grid-filter-row-value]`, `[data-grid-filter-row-operator]`, `[data-grid-filter-add-column]` and
|
|
1192
|
+
`[data-grid-filter-add]`, so the cut lowers a non-negotiable floor — and the applied row is exactly the surface
|
|
1193
|
+
the phase's human check opens («open the filter for "sector": a list of choices, not a field to type into»).
|
|
1194
|
+
|
|
1195
|
+
⚠️ **The value of an applied row is no longer a text field for everything.** It is `GridFilterValueEditor`, and
|
|
1196
|
+
the branch is `control.editor` from `gridColumnFilterControl` — one derivation, not a second «type → control» table.
|
|
1197
|
+
`select`/`multiSelect` render a NATIVE `<select>` populated from the column's `options`; `date`/`datetime`
|
|
1198
|
+
render the `DatePicker`; `number` renders `type="number"`. **No branch falls back to a free field**: a list with
|
|
1199
|
+
zero options is an incomplete DECLARATION (`[data-grid-filter-editor-empty]`), not a reason to go back to
|
|
1200
|
+
typing. The `<select>` is native and not `AppSelect` for a measured reason: `AppSelect`'s trigger is a `<div>`
|
|
1201
|
+
with no `role` and no `tabIndex`, so it exposes no `combobox` and no keyboard target — repairing it is its own
|
|
1202
|
+
task and belongs to every consumer, not to this lane.
|
|
1203
|
+
|
|
1204
|
+
⚠️ **A range is TWO badges, not a new operator.** The vocabulary has no `between`, and adding one would be a red
|
|
1205
|
+
gate (`check-datatable-grid-parity.mjs` compares the two unions in both directions), i.e. a payload eighteen
|
|
1206
|
+
backends cannot read. `getGridFilterRange(filterType)` derives the pair — `ge`/`le` for numbers,
|
|
1207
|
+
`date_after`/`date_before`, `date_time_after`/`date_time_before` — and the composer applies them **in one
|
|
1208
|
+
mutation** through `useGridFilters().addBadges`. Two separate `addBadge` calls would emit **twice**: measured, the counter
|
|
1209
|
+
printed 2 where the criterion asks for 1. ⚠️ **The MECHANISM changed on 2026-09-09 and the consequence did
|
|
1210
|
+
not.** This sentence used to read «because `applyAndEmit` compares the payload inside each updater»; the
|
|
1211
|
+
announcement now leaves the `setState` updater and reads a synchronous mirror (`storedBadgesRef`), because
|
|
1212
|
+
inside the updater React double-invokes it under `StrictMode` and the announcement was doubled with it. Two
|
|
1213
|
+
`addBadge` calls still produce two mutations and therefore two announcements — the reason to use
|
|
1214
|
+
`addBadges` is unchanged.
|
|
1215
|
+
|
|
1216
|
+
⚠️ **Opening the composer works from «Edit value…» and not from the operator radio group — open defect.** The
|
|
1217
|
+
`DismissableLayer` of the closing `DropdownMenu` swallows the `Popover` that mounts in the same frame; from the
|
|
1218
|
+
non-radio item a single `requestAnimationFrame` is enough (measured working), while the radio path's dismissal
|
|
1219
|
+
window lasts longer than a frame because changing the group's value adds a render before the unmount. Tried in
|
|
1220
|
+
four shapes, `modal={false}` included. The only known remedy for the radio path is a 200ms `setTimeout`, which
|
|
1221
|
+
is outside this lane's «zero setTimeout» convention.
|
|
1222
|
+
|
|
1223
|
+
⚠️ **This defect is UNASSIGNED, and the previous assignment was wrong.** It used to read «assigned phase: Riga
|
|
1224
|
+
Zero», on the reasoning that the zero row is the surface the defect lives in. **The zero row is now delivered**
|
|
1225
|
+
(`filterMode`, `onAddFilter`, `onUpdateFilter`, and the `tr[data-grid-filter-row]` block of the DOM contract),
|
|
1226
|
+
and it did **not** touch this defect — nor could it: a filter cell of the zero row opens its popover from a
|
|
1227
|
+
plain `<button>`, with no `DropdownMenu` closing in the same frame, so the dismissal window this defect needs
|
|
1228
|
+
does not exist there. The defect stays where it was measured: the composer opened from the operator radio group
|
|
1229
|
+
of the **inventory panel**.
|
|
1230
|
+
|
|
1231
|
+
⚠️ **And it is not «P9» either — that label is spent twice over in this same document.** «P9» here means **row
|
|
1232
|
+
grouping**, delivered (see «Row grouping (P9)» below, and `groupBy` in the props table); the zero row is «C9»
|
|
1233
|
+
in the phase plan and is named **Riga Zero** everywhere in this document, never numbered. A reader who resolves
|
|
1234
|
+
«P9» to the zero row lands on the wrong phase in both directions, which is exactly how an open defect gets
|
|
1235
|
+
filed against work that is already done.
|
|
1236
|
+
|
|
1237
|
+
**The registry is one place** (also signed) — and since the `⋮` menu was removed it is the **only** place.
|
|
1238
|
+
`columnVisibility` is still the only truth about which columns exist, and the registry panel is the view that
|
|
1239
|
+
shows all of it. ⚠️ **Consequence to know:** that panel is opt-in (`toolbar.columns`, or a consumer-mounted
|
|
1240
|
+
`GridColumnRegistryPanel`), so on a grid that mounts neither there is now **no way, inside the table, to hide a
|
|
1241
|
+
column** — the menu item was that way. Two places to hide a column would have been two sources for one
|
|
1242
|
+
question; one place that the caller may not mount is a different trade, and this is where it is written down.
|
|
1243
|
+
|
|
1244
|
+
⚠️ **Hiding a column drops its filter too**, and that follows from the decision rather than extending it: a
|
|
1245
|
+
hidden column that kept filtering would leave the registry saying one thing ("this column is gone") and the
|
|
1246
|
+
payload another ("…and I am filtering on it"). The pruning is **derived, not an effect**, so the chip and the
|
|
1247
|
+
payload fragment disappear in the **same commit** — in two commits there is a frame where the payload cites a
|
|
1248
|
+
column that no longer exists, and that frame is one the server can receive. Putting the column back brings the
|
|
1249
|
+
filter back, because hiding is not deleting; an effect that cleaned the state would cost a commit and lose the
|
|
1250
|
+
filter irreversibly.
|
|
1251
|
+
|
|
1252
|
+
⚠️ **A filter WITHOUT a column is exempt, and that is a `kind` check and not a special case.** Since Fase 8
|
|
1253
|
+
`isBadgeActive` reads the discriminant first — `badge.kind === "custom" || activeColumnIds.has(badge.columnId)`
|
|
1254
|
+
— so a badge that cites no column survives every visibility change. It is one predicate with four call sites
|
|
1255
|
+
(the exposed list, the emission gate, and the before/after lists of `setColumnVisible`), so the exemption holds
|
|
1256
|
+
for the chip, the count, the payload and the emission at once. Before that line a `kind: "custom"` badge was
|
|
1257
|
+
dropped by all four together, which is the worst shape of the defect: the three readers of the count agreed on
|
|
1258
|
+
a figure that was wrong, and no assertion comparing readers with each other could see it.
|
|
1259
|
+
|
|
1260
|
+
⚠️ **The lifted state emits, and the grid does not emit for it.** Measured in Chrome: the first cut passed the
|
|
1261
|
+
badges to the grid as a prop and _nothing_ emitted — `data-emissions` stayed `0` after applying a filter, so a
|
|
1262
|
+
server-mode consumer would never refetch, and none of the nine specs saw it. A component cannot emit for state
|
|
1263
|
+
it does not own without an effect watching the prop, which is exactly what this lane forbids (an effect that
|
|
1264
|
+
emits is a refetch nobody asked for, at mount and on every parent re-render). So `useGridFilters` takes
|
|
1265
|
+
`onFiltersChange` and calls it **synchronously inside the action** — `addBadge`, `updateBadge`, `removeBadge`,
|
|
1266
|
+
`clearBadges` — which keeps the substance of the invariant, and exposes its own `emissions` counter.
|
|
1267
|
+
|
|
1268
|
+
**Declared deviation** from the phase criterion, which says "`data-emissions` rises by 1": that counter is on
|
|
1269
|
+
the grid, and with lifted filters the grid is no longer who emits for them. The equivalent counter is
|
|
1270
|
+
`emissions` on the lifted state; the spec measures that one, plus the other half of the criterion — that the
|
|
1271
|
+
**grid's** counter does not move when the panel opens or closes.
|
|
1272
|
+
|
|
1273
|
+
⚠️ **Emission is skipped when the payload does not actually change.** Same exit `applyAndEmit` has in the
|
|
1274
|
+
engine, same reason: changing the operator of a filter that has no value does not change the payload (the
|
|
1275
|
+
compiler drops unusable badges), and without the comparison a consumer would refetch on every dropdown click.
|
|
1276
|
+
The comparison is on `JSON.stringify`, because the **order** of `_and` members is part of the parity contract.
|
|
1277
|
+
|
|
1278
|
+
⚠️ **The payload comes from the P2 compiler, unchanged.** `buildGridSearchFilter` keeps its three deliberately
|
|
1279
|
+
copied quirks (valueless operators produce `value: true`; validation looks at `list_multi_select` while
|
|
1280
|
+
compilation wraps `list_single_select`; the simple badge drops the empty string, the advanced condition does
|
|
1281
|
+
not). The 20-operator parity story proves compiler == frozen `buildSearchFilter`; a P7 spec proves lifted ==
|
|
1282
|
+
compiler. Together: lifted == frozen. Without that second link a lifted path that "improved" something would
|
|
1283
|
+
keep parity green while the real payload diverged.
|
|
1284
|
+
|
|
1285
|
+
Its shape, which is easy to assert wrongly — it keys **by field name** and collapses the trivial cases:
|
|
1286
|
+
|
|
1287
|
+
| Badges | `computedFilter` |
|
|
1288
|
+
| ----------- | ----------------------------------------------------------- |
|
|
1289
|
+
| none | `{}` |
|
|
1290
|
+
| one | `{ city: { operator, value } }` — **not** wrapped in `_and` |
|
|
1291
|
+
| two or more | `{ _and: [ { city: … }, { score: … } ] }` |
|
|
1292
|
+
|
|
1293
|
+
⚠️ **Hiding a column removes it from every surface** — header, `<colgroup>` and body cells. Measured with a
|
|
1294
|
+
probe _before_ the fix: `columnVisibility` reached only TanStack, which the grid does not use to render (cells
|
|
1295
|
+
and headers are built from the registry), so unchecking a column changed **nothing** — the registry dropped
|
|
1296
|
+
the filter and left the column. Half of R9, and none of the fifteen specs saw it.
|
|
1297
|
+
|
|
1298
|
+
The pruning happens on the **tree**, before flattening, in one place in the engine. Pruning only the leaves
|
|
1299
|
+
would leave P5's `<thead>` matrix rendering a header for a column that is gone, and `Σ colSpan = leafCount`
|
|
1300
|
+
(P5's criterion 1) would stop holding — a header out of step with the body is the worst defect a table can
|
|
1301
|
+
have, because the data is read under the wrong label. A group left with no leaves disappears: a group `<th>`
|
|
1302
|
+
with `colSpan: 0` is not representable, and one with `colSpan: 1` and nothing under it shifts every column
|
|
1303
|
+
after it. The phantom `<col>` of P4 stays exactly one.
|
|
1304
|
+
|
|
1305
|
+
⚠️ **The last visible column cannot be hidden — the pruning has a floor.** Hide them one by one and the
|
|
1306
|
+
grid keeps pruning until one column is left; hiding that one returns the **whole** tree instead of an empty
|
|
1307
|
+
one. Without the floor `pruneHiddenGridColumns` returns `[]`, which renders an empty `<colgroup>`, an empty
|
|
1308
|
+
`<thead>` and rows with no cells — and that state is **terminal**: with the header gone there is no `⋮` menu
|
|
1309
|
+
left to open, and `toolbar.columns` is absent by default, so on a grid that opts into no slots the header menu
|
|
1310
|
+
was the only way to hide and there was no inverse anywhere. Only a remount got out of it.
|
|
1311
|
+
|
|
1312
|
+
Only **one** place prunes (`visibleTree` in the engine), and that is where the floor lives — never a second
|
|
1313
|
+
pruning site downstream, because two floors would be two sources for one truth. It returns the whole tree
|
|
1314
|
+
rather than "the last column survives", because _which_ column is last depends on the order the boxes were
|
|
1315
|
+
unchecked, i.e. it would be a state that has to remember its own history. The visibility **map is untouched**:
|
|
1316
|
+
the boxes stay where the user put them, and re-checking any one column resumes real pruning in the same commit.
|
|
1317
|
+
Measured on the live path with four columns, **before** the write-side refusal below existed: unchecking them
|
|
1318
|
+
in turn gave `[city, score, azioni]` → `[score, azioni]` → `[azioni]` → **`[name, city, score, azioni]`** (the
|
|
1319
|
+
floor), then re-checking `city` gave `[city]`. The last of those four gestures is now refused upstream, so the
|
|
1320
|
+
floor is no longer reachable by unchecking — it is the net, not the behaviour.
|
|
1321
|
+
|
|
1322
|
+
⚠️ **The hide affordance is `disabled`, and the write path refuses too — the all-hidden state is
|
|
1323
|
+
unreachable rather than recovered afterwards.** ⚠️ It used to read «BOTH hide affordances», and the second was
|
|
1324
|
+
the column menu's `[data-grid-column-hide]` item, `disabled` on the same `engine.headers.length > 1` condition;
|
|
1325
|
+
the menu is gone, so the guard that counted **visible leaves** now has one surface instead of two. Two guards
|
|
1326
|
+
remain, in the order a gesture meets them:
|
|
1327
|
+
|
|
1328
|
+
- the panel's `[data-grid-column-registry-toggle]` checkbox is `disabled` on a **visible** column when only one
|
|
1329
|
+
remains visible. Re-checking a **hidden** column is always allowed: the panel is the place a column is put
|
|
1330
|
+
_back_, and disabling that would remove its reason to exist. The panel's `puoNascondere` prop defaults to
|
|
1331
|
+
`true`, so the three stories that mount it standalone — with a state of their own and no grid floor — are
|
|
1332
|
+
unchanged;
|
|
1333
|
+
- `changeColumnVisibility` **returns early** on `!visible && !puoNascondereUnaColonna`, so even a call that
|
|
1334
|
+
bypasses both controls writes nothing. This is a refusal on the write path, not a second pruning site: the
|
|
1335
|
+
map never reaches the all-hidden state, so nothing downstream has to have an opinion about it.
|
|
1336
|
+
|
|
1337
|
+
The floor in `visibleTree` stays as the **safety net**, for the paths that do not go through that guard (a
|
|
1338
|
+
`columnVisibility` prop arriving already all-`false` from a consumer, or a preset restoring one). It also
|
|
1339
|
+
closes a disagreement that would otherwise be live: "which columns are visible" is read from the **tree** by
|
|
1340
|
+
the header/body/`<colgroup>` and from the **map** by `use-grid-filters.ts` (`activeColumnIds`), and with every
|
|
1341
|
+
box unchecked the tree would say "all four" while the map said "none". Unreachable now, because the write
|
|
1342
|
+
refuses.
|
|
1343
|
+
|
|
1344
|
+
⚠️ **Changing column visibility emits**, when the payload actually changes. Hiding a column prunes its badge,
|
|
1345
|
+
so `computedFilter` goes from `{_and:[{city},{score}]}` to `{score}` — and before the fix that change was
|
|
1346
|
+
silent, so a server-mode consumer kept showing rows filtered by city while the chip was already gone. It was
|
|
1347
|
+
also a pure asymmetry: the same payload change emitted from a chip and stayed quiet from the registry's
|
|
1348
|
+
checkbox. This is a **view action that emits**, and it does not contradict "view does not emit": that rule is
|
|
1349
|
+
about view actions which do not touch the payload (density, width, full screen, opening the panel). The gate is
|
|
1350
|
+
the same as `applyAndEmit`'s — hiding a column that carries no filter emits nothing.
|
|
1351
|
+
|
|
1352
|
+
⚠️ **Changing a lifted filter resets pagination to page 0.** The reducer's `query/filters` action does that,
|
|
1353
|
+
because a filtered result has a different number of pages — but with lifted filters that action is never
|
|
1354
|
+
dispatched, so the page stayed put: sitting on page 3, change a filter, and the payload still asks for
|
|
1355
|
+
`pageIndex: 3` of a set that may have one page. An empty table with no explanation. The reset compares badge
|
|
1356
|
+
**content** — `JSON.stringify` of the lifted array — and lasts until the next explicit page action.
|
|
1357
|
+
⚠️ **IT COMPARED IDENTITY UNTIL 2026-09-09, and the audit named this very sentence as the seat of that
|
|
1358
|
+
promise.** Identity was the wrong test because the promise it rested on is not one the grid can enforce:
|
|
1359
|
+
a call-site writing `filters={badges.filter(Boolean)}` inline hands over a NEW array on every render, so
|
|
1360
|
+
the flag rose on every render, the page returned to 0 right after each click on «next», and pagination
|
|
1361
|
+
was nailed to page 1 with nothing to signal it. Comparing content costs a `stringify` of a short array
|
|
1362
|
+
and removes the requirement that the consumer memoize. Nailed by
|
|
1363
|
+
`app-data-grid-fix-motore.stories.tsx :: UnArrayRicostruitoNonInchiodaLaPagina`, which rebuilds the
|
|
1364
|
+
array in the JSX on purpose.
|
|
1365
|
+
|
|
1366
|
+
⚠️ **The panel does not offer hidden columns.** Offering one means adding a filter that does nothing visible —
|
|
1367
|
+
the badge is born and pruned immediately — i.e. a control that accepts an action and throws it away.
|
|
1368
|
+
|
|
1369
|
+
⚠️ **The registry panel lists every leaf column, `accessorKey` or not** — corrected in Fase 3. Its `columns`
|
|
1370
|
+
prop was typed `GridFilterColumn[]`, and the only producer of that shape (`toGridFilterColumns`) **drops**
|
|
1371
|
+
columns without an `accessorKey` — i.e. every column from `createGridDisplayColumn` and
|
|
1372
|
+
`createGridActionColumn`. Two silent consequences, both measured on the live path with
|
|
1373
|
+
`[name, azioni, city, score]` where `azioni` is an action column: the order the panel emits did not **name**
|
|
1374
|
+
`azioni`, so in the engine it collapsed to `Number.MAX_SAFE_INTEGER` and slid to the tail — one press of «move
|
|
1375
|
+
later» on `name` rendered `[city, name, score, azioni]`, the action column jumping from position 2 to 4, with
|
|
1376
|
+
`<col>`, `<th>` and `<td>` all agreeing on the wrong table because they all derive from the same tree. And
|
|
1377
|
+
«Hide this column» on such a column was **unrecoverable**, because the checkbox that puts it back was not in
|
|
1378
|
+
the panel. After the fix the same gesture renders `[azioni, name, city, score]`. The panel needs `{ id, label }` plus two OPTIONAL fields it uses for the row icon and the
|
|
1379
|
+
reorder constraint, so its prop is `readonly GridColumnRegistryEntry[]` where the entry is
|
|
1380
|
+
`{ id: string; label: string; columnType?: GridColumnType; pinned?: "left" | "right" }`
|
|
1381
|
+
(`GridColumnRegistryEntry`, a public export). ⚠️ Both extra fields being OPTIONAL is a silent trap: a mount
|
|
1382
|
+
that omits them compiles, passes every gate, and renders identical icons with an inert reorder constraint — a `GridFilterColumn` is still assignable to it, so a consumer that mounts the
|
|
1383
|
+
panel with filter columns keeps compiling.
|
|
1384
|
+
|
|
1385
|
+
⚠️ **The value field uses `size="sm"`, and its focus ring is the primitive's.** `Input` is a _wrapper_: a
|
|
1386
|
+
`<div>` carrying border, focus ring and height, plus an inner `<input>` that receives `className`. So a height
|
|
1387
|
+
class passed from outside lands on the inner input, not the wrapper — measured, `!h-6` gave a 20px input inside
|
|
1388
|
+
a 36px wrapper, i.e. the field misaligned by 8px from its neighbours and overflowing its row by 4px. The
|
|
1389
|
+
wrapper exposes no class prop, so `size` is the only lever and `sm` is its smallest step (32px). **Declared
|
|
1390
|
+
limit:** the contrast-corrected focus ring cannot be applied to this one control for the same reason — the ring
|
|
1391
|
+
lives on the wrapper. Fixing it would mean touching `input.tsx`, which is shared surface the frozen `DataTable`
|
|
1392
|
+
renders, and would restyle every input in every consumer. That is a task of its own, and it applies to all of
|
|
1393
|
+
them.
|
|
1394
|
+
|
|
1395
|
+
⚠️ **Badge filters do not filter client-side.** They build the payload a server would receive; the grid keeps
|
|
1396
|
+
filtering locally from `search` only. That is what the phase criterion asks for ("with the section hidden the
|
|
1397
|
+
grid keeps filtering from `search`"), and it is the honest split for a component whose server story is
|
|
1398
|
+
`onQueryChange` — but a consumer rendering a lifted filter over local data will see the rows unchanged until
|
|
1399
|
+
it filters the data itself.
|
|
1400
|
+
|
|
1401
|
+
⚠️ **A column with no `accessorKey` is not filterable, and is dropped rather than defaulted.**
|
|
1402
|
+
`createGridDisplayColumn` legitimately produces one (an actions or presentation column), and `accessorKey` is
|
|
1403
|
+
the key the fragment enters the payload under — so filtering on it would send a condition on a field the
|
|
1404
|
+
server does not have. Falling back to `column.id` would be worse than the problem: it would fire a query on an
|
|
1405
|
+
invented name, and the place that gets discovered is somebody else's backend.
|
|
1406
|
+
|
|
1407
|
+
### Filters the application declares — the `GridFilterControl` registry (R15, Fase 8)
|
|
1408
|
+
|
|
1409
|
+
A filter that has **no column** — «only my favourites», «older than N days», a saved segment — used to be
|
|
1410
|
+
unrepresentable: every badge cited a column, and column pruning dropped anything whose `columnId` matched no
|
|
1411
|
+
leaf. From Fase 8 such a filter is a first-class citizen. It is declared once as a **registry entry**, it
|
|
1412
|
+
enters the state as a badge with `kind: "custom"`, and from there it is a filter like any other: it is
|
|
1413
|
+
counted, it is cleared by «Reset all», it goes into `computedFilter`, and hiding a column never touches it.
|
|
1414
|
+
|
|
1415
|
+
```ts
|
|
1416
|
+
import {
|
|
1417
|
+
AppDataGrid,
|
|
1418
|
+
createGridCustomFilterBadge,
|
|
1419
|
+
useGridFilters,
|
|
1420
|
+
type GridFilterControl,
|
|
1421
|
+
type GridFilterControlRegistry,
|
|
1422
|
+
} from "laif-ds";
|
|
1423
|
+
|
|
1424
|
+
const olderThan: GridFilterControl = {
|
|
1425
|
+
// The KEY of the entry in the registry — a string, not a closed literal:
|
|
1426
|
+
// a closed union here would make the registry extensible only by changing
|
|
1427
|
+
// the library, which is the opposite of what R15 is for.
|
|
1428
|
+
type: "olderThan",
|
|
1429
|
+
// The control that writes the value. THE GRID DOES NOT RENDER IT: it is
|
|
1430
|
+
// rendered by the application, wherever the filter is created.
|
|
1431
|
+
Control: ({ value, onChange, label }) => <MyDaysInput …/>,
|
|
1432
|
+
// MANDATORY. Value -> payload piece. `null` means «not applied».
|
|
1433
|
+
toFragment: (value, ctx) => ({ [ctx.field]: { operator: "ge", value } }),
|
|
1434
|
+
// MANDATORY. Value -> text. The ONLY source of a custom filter's text.
|
|
1435
|
+
describe: (value, i18n) => `Older than ${String(value)} days`,
|
|
1436
|
+
};
|
|
1437
|
+
|
|
1438
|
+
const registry: GridFilterControlRegistry = { olderThan };
|
|
1439
|
+
```
|
|
1440
|
+
|
|
1441
|
+
**`toFragment` and `describe` are a mandatory PAIR, and the type enforces it.** You register a control **and**
|
|
1442
|
+
its translation, never one without the other: omitting either does not compile. A filter that can produce a
|
|
1443
|
+
payload but cannot say what it is called is exactly the defect this phase measures — a badge saying «12» next
|
|
1444
|
+
to 10 filled cells, and no line on screen saying where the other 2 are. Both are **properties with an arrow
|
|
1445
|
+
type, never methods**: under `strict` the parameters of an arrow property are contravariant and those of a
|
|
1446
|
+
method are bivariant, so the method form would let a wrong signature through. The type is deliberately **not
|
|
1447
|
+
generic on the value**: a `GridFilterControl<string>` would not be assignable to the registry that has to
|
|
1448
|
+
contain it, again by contravariance — narrow the value inside the two functions instead.
|
|
1449
|
+
|
|
1450
|
+
**One constructor, and only one.** `createGridCustomFilterBadge(control, value, i18n, id)` is the single place
|
|
1451
|
+
in the library where `describe` and `toFragment` are ever called:
|
|
1452
|
+
|
|
1453
|
+
```ts
|
|
1454
|
+
const filters = useGridFilters({
|
|
1455
|
+
columns,
|
|
1456
|
+
initialBadges: [createGridCustomFilterBadge(olderThan, 30, i18n, "b-older")],
|
|
1457
|
+
});
|
|
1458
|
+
```
|
|
1459
|
+
|
|
1460
|
+
It fills `kind: "custom"`, `customKey` (= `control.type`), `customLabel` (= the ONE call to `describe`),
|
|
1461
|
+
`customFragment` (= the ONE call to `toFragment`, `undefined` when it returned `null`), and the four column
|
|
1462
|
+
fields as INERT placeholders — `columnId` gets the reserved prefix `grid-custom-filter:<type>` (an id no
|
|
1463
|
+
column has, so a header never announces itself «filtered» because of it), `columnLabel` gets the raw registry
|
|
1464
|
+
key on purpose, so a surface that wrongly recomposed «column + operator + value» would show `olderThan` on
|
|
1465
|
+
screen instead of a plausible sentence. **To change the value, rebuild the badge with the SAME `id`** and pass
|
|
1466
|
+
it to `updateBadge`: text and fragment then change together. Recomputing only the fragment would leave the
|
|
1467
|
+
previous value's description on screen.
|
|
1468
|
+
|
|
1469
|
+
**Where a filter without a column is seen.** Four surfaces, one text — `customLabel`, and nothing composed
|
|
1470
|
+
locally:
|
|
1471
|
+
|
|
1472
|
+
| Surface | What it shows |
|
|
1473
|
+
| --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
1474
|
+
| the chip (`GridFilterChips`) | `customLabel` as visible text, as the trigger's accessible name and as the menu heading — one string |
|
|
1475
|
+
| the applied panel row (`[data-grid-filter-row-kind="custom"]`) | `customLabel` plus its «×». No operator select, no value editor: there is no vocabulary to offer |
|
|
1476
|
+
| the dashed toolbar group (`[data-grid-toolbar-custom-filters]`) | a VISIBLE caption («2 senza colonna») plus `customLabel` in full and a named `<button>` «Remove filter …» per entry |
|
|
1477
|
+
| the status line (`[data-grid-live-region]`) | a tail on the filters clause: «12 filters active, 2 without a column: …» — the count AND the names |
|
|
1478
|
+
|
|
1479
|
+
The toolbar group needs `onRemoveFilter` **and** lifted `filters` to render (see the prop table): its «×» is a
|
|
1480
|
+
real button, the grid cannot remove a badge from its own slice, and a button that fires a handler and changes
|
|
1481
|
+
nothing is a dead command. The status-line tail appears only when there is at least one filter without a
|
|
1482
|
+
column — «0 without a column» on every grid would be noise.
|
|
1483
|
+
|
|
1484
|
+
**On screen and announced are two different halves.** The status-line tail lives in the single `role="status"`
|
|
1485
|
+
node, which is `sr-only`: it is what a screen reader hears, never what a sighted reader sees. The sentence that
|
|
1486
|
+
says where the missing filters are — «the badge says 12, the panel has 10 filled cells, 2 have no column» — is
|
|
1487
|
+
declared on screen by `[data-grid-toolbar-custom-filters-caption]` plus the full names next to it. A probe that
|
|
1488
|
+
only reads the live region's text cannot tell the two apart; measure the caption's rendered width.
|
|
1489
|
+
|
|
1490
|
+
**Known limit, declared:** the status-line tail enumerates EVERY filter without a column, with no cap. With
|
|
1491
|
+
twelve of them a screen reader re-reads twelve full sentences on every gesture that touches the filters. It is
|
|
1492
|
+
not truncated today because no scene in the lane has more than three, and a truncation branch nothing exercises
|
|
1493
|
+
is a branch that is never run.
|
|
1494
|
+
|
|
1495
|
+
**What the compiler does with it.** A `kind: "custom"` badge leaves `usableBadges` before `badgeToCondition`,
|
|
1496
|
+
so it never goes through the rewrite that keys a condition by `columnAccessorKey` and passes the value through
|
|
1497
|
+
`conditionValue`. Its `customFragment` is collected by `gridCustomFragments` and appended, **byte for byte**,
|
|
1498
|
+
at the END of the conjunction — so a payload with no custom filters is identical to what it was before this
|
|
1499
|
+
phase existed. Emission stays single: it goes through the same `applyAndEmit` as everything else, with no
|
|
1500
|
+
extra branch — and therefore inherits its coalescing, since a custom filter lands as a `query/filters` action
|
|
1501
|
+
like any other.
|
|
1502
|
+
|
|
1503
|
+
**Three i18n keys, all optional** (`GridI18n["filters"]`, English by default): `removeFilterNamed` («Remove
|
|
1504
|
+
filter», the prefix composed with the name for the «×»), `customFilters` («Filters without a column», the
|
|
1505
|
+
accessible name of the dashed `role="group"`), `withoutColumn` («without a column», the unit after the count
|
|
1506
|
+
in the status line).
|
|
1507
|
+
|
|
1508
|
+
**Declared limits.** The library does not render a registry entry's `Control` anywhere — the value of a custom
|
|
1509
|
+
filter is written where the filter is created, not from the panel, and the panel therefore shows a custom
|
|
1510
|
+
filter _named and removable_, not _editable_. And `describe` produces text of uncontrolled length, which is
|
|
1511
|
+
**not truncated** in the dashed group: «in full» is literal, the group wraps, and one very long name widens
|
|
1512
|
+
the toolbar band instead of overflowing it.
|
|
1513
|
+
|
|
1514
|
+
### Infinite source and skeleton rows (P8)
|
|
1515
|
+
|
|
1516
|
+
`infinite={{ hasMore }}` turns on paging-while-scrolling. Requires `virtualized` to be meaningful.
|
|
1517
|
+
|
|
1518
|
+
```tsx
|
|
1519
|
+
const [rows, setRows] = useState<Row[]>([]);
|
|
1520
|
+
const [loading, setLoading] = useState(false);
|
|
1521
|
+
const [hasMore, setHasMore] = useState(true);
|
|
1522
|
+
|
|
1523
|
+
<AppDataGrid
|
|
1524
|
+
columns={columns}
|
|
1525
|
+
data={rows}
|
|
1526
|
+
virtualized
|
|
1527
|
+
viewportHeight={520}
|
|
1528
|
+
pageSize={30}
|
|
1529
|
+
loading={loading}
|
|
1530
|
+
infinite={{ hasMore }}
|
|
1531
|
+
onQueryChange={async (state) => {
|
|
1532
|
+
setLoading(true);
|
|
1533
|
+
const page = await api.list({ ...state, page: state.pagination.pageIndex });
|
|
1534
|
+
// pageIndex 0 means a NEW list (sort, filter, search): replace.
|
|
1535
|
+
// Anything above means "the next page of this list": append.
|
|
1536
|
+
setRows((prev) =>
|
|
1537
|
+
state.pagination.pageIndex === 0 ? page.rows : [...prev, ...page.rows],
|
|
1538
|
+
);
|
|
1539
|
+
setHasMore(page.hasMore);
|
|
1540
|
+
setLoading(false);
|
|
1541
|
+
}}
|
|
1542
|
+
/>;
|
|
1543
|
+
```
|
|
1544
|
+
|
|
1545
|
+
**The consumer appends, the grid does not.** The grid holds no copy of `data`. Two lists that must
|
|
1546
|
+
stay equal are two lists that eventually are not.
|
|
1547
|
+
|
|
1548
|
+
**`hasMore` is declared, never inferred.** The obvious inference — "a page came back shorter than
|
|
1549
|
+
`pageSize`, so we are done" — is wrong against any API that can legitimately answer short (a time
|
|
1550
|
+
window, a permission, a cost ceiling), and getting it wrong silently truncates a list that still had
|
|
1551
|
+
rows. With `hasMore: false` the grid stops asking **permanently**; scrolling further does not restart
|
|
1552
|
+
it.
|
|
1553
|
+
|
|
1554
|
+
#### What the phase changed inside
|
|
1555
|
+
|
|
1556
|
+
| Before P8 | From P8 |
|
|
1557
|
+
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1558
|
+
| `loading` was an either/or: skeletons **or** data | They **coexist**; the shell picks append vs replace |
|
|
1559
|
+
| The virtualizer counted `data.length` | It counts **what is rendered** — data rows **plus** skeleton rows |
|
|
1560
|
+
| `enabled: virtualized && !loading` — virtualization switched off while loading | `enabled: virtualized`; when there is nothing to virtualize `count` is 0 and says so |
|
|
1561
|
+
| `query/sort` and `query/sort-set` left `pageIndex` where it was | They **reset it to 0**, like search and filters already did. Sorting from page 7 asked for page 7 of a new order — rows unrelated to what you were looking at; under an infinite source it made the emitted payload plainly false |
|
|
1562
|
+
|
|
1563
|
+
That third row is the P3/P4 debt paid off. The old line was correct for the reason written at the
|
|
1564
|
+
time — while loading, the rendered rows were not the data rows, so spacers sized on `data.length`
|
|
1565
|
+
produced a 359,208 px bottom spacer under ten skeletons — but it was a remedy, not the cause. The
|
|
1566
|
+
cause is removed: **the virtualizer counts what is rendered**, so the spacers are exact in both
|
|
1567
|
+
states and a scrollable void is no longer a case to exclude but an unrepresentable state.
|
|
1568
|
+
|
|
1569
|
+
#### The invariants the phase rests on
|
|
1570
|
+
|
|
1571
|
+
- **A skeleton row is exactly as tall as a data row**, at all three densities (32 / 36 / 48). Both
|
|
1572
|
+
read `gridRowHeightClass[density]` — one source, not two. Skeletons enter the virtualizer's count
|
|
1573
|
+
as ordinary rows, so a pixel of difference per row scales with the length of the list.
|
|
1574
|
+
- **A skeleton has one cell per _visible_ leaf column, never a `colSpan`.** `headers` already carries
|
|
1575
|
+
only the unhidden leaves (P5 flattens the tree, P7 prunes it), so the count holds by construction.
|
|
1576
|
+
A `colSpan` here would skew the `headers` chain that P5 derives by walking the header colSpans —
|
|
1577
|
+
and skew it _only while loading_, which is the state nobody checks.
|
|
1578
|
+
- **One request per page, and never more than one in flight.** The loader's guard is the key
|
|
1579
|
+
`querySignature:rowCount`, which contains nothing that requesting itself changes. A boolean
|
|
1580
|
+
"in flight" flag is the wrong shape: the point where you clear it is where the defect hides — and
|
|
1581
|
+
neither is a counter kept inside the reducer, which cannot see the **lifted filters** of P7 (those
|
|
1582
|
+
never pass through it), so a chip change would leave the key still and freeze the list.
|
|
1583
|
+
- **The page asked for is the request count, not a function of the rows.** A page shorter than
|
|
1584
|
+
`pageSize` is legitimate, and both obvious derivations break on it: `floor(rows / pageSize)` re-asks
|
|
1585
|
+
the page that just arrived, and the same on the _filtered_ row model asks for a page decided by how
|
|
1586
|
+
many rows the client filter kept. On mount the count is seeded from the data already in hand, so a
|
|
1587
|
+
consumer that server-renders or caches the first page is not made to re-fetch it.
|
|
1588
|
+
- **A page in flight is announced.** `aria-busy` on the root plus a polite live region, because the
|
|
1589
|
+
skeletons are `aria-hidden` and without it a screen-reader user has no way to know a page is coming
|
|
1590
|
+
or has arrived (WCAG 4.1.3 Status Messages, AA).
|
|
1591
|
+
- **The end of the source is a state, not a loop.** `hasMore: false` exits immediately, forever.
|
|
1592
|
+
- **One fetch on mount, `StrictMode` included.** There is no dedicated first-page path: mount is
|
|
1593
|
+
simply the case where the list is zero tall, so the distance to the bottom is zero and the single
|
|
1594
|
+
trigger fires once. React refs survive StrictMode's double effect invocation, so the second pass
|
|
1595
|
+
finds the same key and exits.
|
|
1596
|
+
|
|
1597
|
+
#### Declared limits
|
|
1598
|
+
|
|
1599
|
+
- **`aria-rowcount` is `-1` while `hasMore` is true.** ARIA requires it when the total row count is
|
|
1600
|
+
unknown, which is what an infinite source is. It goes back to the row count once the source is
|
|
1601
|
+
exhausted, and outside infinite mode it never changes.
|
|
1602
|
+
- **A consumer that answers with zero new rows while keeping `hasMore: true` stops the grid.** The
|
|
1603
|
+
guard key does not move, so nothing more is requested. Between the two ways of mishandling a
|
|
1604
|
+
consumer that contradicts itself, this is the one that does not drain a battery.
|
|
1605
|
+
- **The trigger is scroll position only.** There is no keyboard path to request the next page
|
|
1606
|
+
without scrolling. This is the one accessibility gap the phase leaves open.
|
|
1607
|
+
- **⚠️ A custom `emptyComponent` is shown but not announced; the default message is announced
|
|
1608
|
+
instead.** **From Fase 5 the mechanism is reconfirmed for all THREE outcomes without data, and it
|
|
1609
|
+
is unchanged.** There is exactly ONE `[role="status"]` in the grid in the empty, error and loading
|
|
1610
|
+
states alike — measured per grid, not per canvas; the visible nodes of all three carry **no**
|
|
1611
|
+
`role`; and what is announced is always a STRING chosen by a single ternary whose branch order is
|
|
1612
|
+
loading → error → empty (error FIRST of the two, because a failed fetch also has zero rows):
|
|
1613
|
+
`i18n.notFoundMessage` for the empty outcome, `i18n.table.errorMessage` for the error one — never
|
|
1614
|
+
the error surface itself, which contains a «Retry» button a screen reader would otherwise read
|
|
1615
|
+
twice. The empty state is announced through the permanent `[data-grid-live-region]`, which is
|
|
1616
|
+
mounted empty and filled when the state arrives — a live region created together with its text has
|
|
1617
|
+
no mutation to announce, so screen readers stay silent, which is the defect this arrangement fixed.
|
|
1618
|
+
What goes into that region is always `i18n.notFoundMessage`, never `emptyComponent`, **and the
|
|
1619
|
+
reason is that they are different kinds of thing**: `emptyComponent` is an arbitrary `ReactNode` —
|
|
1620
|
+
an illustration, a fragment, a button — while a live region announces _text_. Putting a node tree
|
|
1621
|
+
in there yields an unpredictable announcement or none at all, and any interactive element inside it
|
|
1622
|
+
would be read twice, once in the region and once on the page.
|
|
1623
|
+
|
|
1624
|
+
The accepted consequence: pass a custom `emptyComponent` and a sighted user reads your wording while
|
|
1625
|
+
a screen-reader user hears "No results found". Both are true statements about the same state, but
|
|
1626
|
+
they are not the same sentence. If you need them to match, change `i18n.notFoundMessage` alongside
|
|
1627
|
+
your `emptyComponent`; there is deliberately no separate announcement prop, because a second way to
|
|
1628
|
+
say the same thing is a second thing to keep in sync.
|
|
1629
|
+
|
|
1630
|
+
- **⚠️ Without `getRowId`, row identity is positional and the selection does not survive a
|
|
1631
|
+
re-sort.** TanStack's default row id is the row's index in `data`, and the grid does not override
|
|
1632
|
+
it unless the consumer passes `getRowId`. In infinite mode every re-sort and every filter change
|
|
1633
|
+
goes through `restart`: the list is rebuilt, and a selection held as `Record<rowId, boolean>` keeps
|
|
1634
|
+
pointing at the same indices — which now hold different records. Nothing looks wrong: the ticks are
|
|
1635
|
+
where ticks should be, on the wrong rows. This is a **data** defect, not a visual one, which is why
|
|
1636
|
+
it is written down rather than left to be discovered. Pass `getRowId` and the selection follows the
|
|
1637
|
+
records. Not passing it keeps the old behaviour, deliberately: making stable identity the default
|
|
1638
|
+
would change what every existing call-site's selection means.
|
|
1639
|
+
- **⚠️ `infinite` is ignored when `paginated` is on.** The two are representable together and they
|
|
1640
|
+
break together, because they write the same number: `query/load-more` advances
|
|
1641
|
+
`pagination.pageIndex`, which in paginated mode is exactly what the page control renders and what
|
|
1642
|
+
`getPaginationRowModel` slices the client-side rows by. Traced with `paginated pageSize={10}
|
|
1643
|
+
infinite={{hasMore:true}}` over ten rows: the content (360px) is shorter than the viewport (480px),
|
|
1644
|
+
so the distance to the bottom is ≤ 0 at mount, a load-more fires, `pageIndex` becomes 1, the control
|
|
1645
|
+
jumps to page 2 on its own and `getRowModel()` returns `data.slice(10, 20)` — **an empty table with
|
|
1646
|
+
ten rows in hand**, with nobody having touched anything.
|
|
1647
|
+
|
|
1648
|
+
The loader is therefore disabled whenever `paginated` is on (`enabled: infinite !== undefined &&
|
|
1649
|
+
!paginated`). Narrowing `AppDataGridProps` so the pair could not be written was the rejected
|
|
1650
|
+
alternative: it would reach the call-sites and the codemod. The accepted cost is that `infinite`
|
|
1651
|
+
does nothing next to `paginated` — **which is why it is written down here rather than left silent.
|
|
1652
|
+
A prop that says nothing is worse than a prop that is not there.** Pick one: paginate, or scroll.
|
|
1653
|
+
|
|
1654
|
+
- **`infinite` does not require `virtualized` in the type**, and the two are orthogonal, so the
|
|
1655
|
+
combination `infinite` without `virtualized` is representable. It is made _safe_ rather than
|
|
1656
|
+
forbidden: the scroll container takes its `viewportHeight` whenever either is on, because without a
|
|
1657
|
+
bounded height the page scrolls instead of the container and the loader would chain one request per
|
|
1658
|
+
page until the source ran dry, with nobody having scrolled.
|
|
1659
|
+
_(Historical: this is the rule as P8 declared it. P9 widened it — the viewport is bounded whenever
|
|
1660
|
+
`viewportHeight` is passed explicitly too. See the `viewportHeight` row in the props table.)_
|
|
1661
|
+
|
|
1662
|
+
### Riga Zero — the filters inside the columns (C9)
|
|
1663
|
+
|
|
1664
|
+
`filterMode="row"` moves the filters from the overlay inventory into the table: a **fourth `<tr>` of
|
|
1665
|
+
`<thead>`**, one control under each column. `"panel"` stays the default, and the switch between the two lives
|
|
1666
|
+
in the **caller**, never in the grid.
|
|
1667
|
+
|
|
1668
|
+
⚠️ **Telling the zero row apart from the header row is done by a HORIZONTAL rule, and the vertical marks are
|
|
1669
|
+
deliberately weak.** Both halves come from a measurement against ag-grid, taken on 2026-09-10 after the owner
|
|
1670
|
+
reported that the two rows could not be distinguished on the real app:
|
|
1671
|
+
|
|
1672
|
+
| | ag-grid (Quartz dark) | AppDataGrid |
|
|
1673
|
+
| --- | --- | --- |
|
|
1674
|
+
| horizontal rule header ↔ zero row | `border-top: 1px rgba(255,255,255,.15)` on the filter cell | `gridHeaderRowSeparatorShadow` on the LAST header row |
|
|
1675
|
+
| vertical column mark, header row | `2 × 14.4px` tick — **30%** of the 48px row | full-height 1px rule inside the resize handle |
|
|
1676
|
+
| vertical column mark, zero row | **none at all** | `2 × 12px` tick — 33% of the 36px row |
|
|
1677
|
+
| row background | identical on both | identical on both |
|
|
1678
|
+
| row height | 48 / 48, **identical** | 32 / 36 |
|
|
1679
|
+
|
|
1680
|
+
⚠️ **The horizontal rule is the predicate being corrected, not an exception added to it.** `grid-header.tsx`
|
|
1681
|
+
decides the rule with "does it have a row below it", and used to deny it to the last level because *"there the
|
|
1682
|
+
separation is done by the `<thead>`'s shadow"* — a premise that is **false** with the zero row mounted, since
|
|
1683
|
+
`gridStickyHeaderShadow` sits under the `<thead>`, i.e. under the FILTERS. So the zero row now counts as a row
|
|
1684
|
+
below (`filterRowVisible` on `GridHeaderProps`), and the two signals mark two different boundaries rather than
|
|
1685
|
+
stacking into 2px. The flag is **not** derived from `filterRow !== undefined`: that node can be present and
|
|
1686
|
+
render `null` when the toggle is closed (R13), and deriving it from the node would paint a rule separating the
|
|
1687
|
+
header from itself.
|
|
1688
|
+
|
|
1689
|
+
⚠️ **The zero row's column mark is a POSITIONED NODE (`[data-grid-filter-cell-tick]`), not a `box-shadow`, and
|
|
1690
|
+
three forms were tried in one day — read all three or the next pass redoes them.** (1) A `box-shadow`
|
|
1691
|
+
(`inset 1px`) on every cell past the first: because an `inset` shadow covers the box's entire side, the mark
|
|
1692
|
+
came out as tall as the cell and **welded** the zero row to the headers — the opposite of the defect it was
|
|
1693
|
+
fixing. (2) A short node centred at a third of the row height: the delivered form. (3) No mark at all, on the
|
|
1694
|
+
measurement that between two bordered controls (borders at x 446 and 470) a third mark at 458 makes three
|
|
1695
|
+
verticals inside 24px — which is also what ag-grid does, its filter row having none. The owner then asked for
|
|
1696
|
+
the mark back: the measurement described a real crowding, the preference is theirs, and form (2) wins. The mark
|
|
1697
|
+
is centred (anchored to the top it would start from the horizontal rule and the two would read as a corner), it
|
|
1698
|
+
sits on the cell's LEFT side so cell `i` marks the boundary `i-1 | i`, and ⚠️ **the FIRST cell carries none** —
|
|
1699
|
+
to its left there is no boundary, only the table's edge or the selection column, whose header
|
|
1700
|
+
(`GridSelectAllHeader`) carries no resize mark. ⚠️ **On a right-pinned block's edge it is not emitted either:**
|
|
1701
|
+
that side already carries the pinned boundary (crisp plus blur).
|
|
1702
|
+
|
|
1703
|
+
⚠️ **The control is `h-7` (28px) in a 36px row, and the 4px of air on each side are the point, not a rounding.**
|
|
1704
|
+
With a 32px control there were only 2px, and there is a horizontal rule immediately above (the one this section
|
|
1705
|
+
opens with) and the `<thead>`'s shadow immediately below: the four lines fell as two pairs 2px apart — measured
|
|
1706
|
+
at y 253/255 and 287/289 — which reads as one crooked doubled line rather than two boundaries. The control was
|
|
1707
|
+
shrunk rather than the row raised because `gridFilterRowHeight` **is** `designTokens.table.rowHeight`, is
|
|
1708
|
+
exported, and feeds `gridViewportHeight`'s `hasFilterRow` addend: raising the row means splitting a new token
|
|
1709
|
+
out and moving the viewport contract, while this is one class.
|
|
1710
|
+
|
|
1711
|
+
⚠️ **The header label carries the FULL tone, and the `<th>` does not.** Measured on the app: the leaf label and
|
|
1712
|
+
a filter's resting text were the *same* colour at the *same* size (`rgb(173,173,173)`, 10px), separated only by
|
|
1713
|
+
weight (600 vs 400) — the two rows literally had one voice. The label span therefore takes `text-d-foreground`
|
|
1714
|
+
while the `<th>` keeps `text-d-muted-foreground`, because the sort glyph INHERITS from the `<th>`: raising the
|
|
1715
|
+
tone there would light up the "sortable, not sorted" affordance on every column, which is the same class of
|
|
1716
|
+
defect `SortGlyph` already declares for the accent tint.
|
|
1717
|
+
|
|
1718
|
+
⚠️ **A cell whose column carries a filter draws its control with an ACCENT border, so the row says at a glance
|
|
1719
|
+
which columns are constrained.** The predicate is `badge !== undefined`, **not** "has a value", and the
|
|
1720
|
+
difference is not academic: an operator of arity `"none"` (`eq_null`, `checked`) filters without a value, and a
|
|
1721
|
+
badge whose value was emptied by hand stays alive until it is removed — with the wrong predicate the cell would
|
|
1722
|
+
claim "no filter" on a column the table is constraining. ⚠️ **It agrees with the header's funnel by
|
|
1723
|
+
construction, not by convention:** `filteredColumnIds` is `new Set(engine.state.query.filters.map(b =>
|
|
1724
|
+
b.columnId))` and the `badges` this row receives ARE `engine.state.query.filters` — one list, passed from one
|
|
1725
|
+
place. Deriving the same truth twice is the failure mode `filteredColumnIds`' own docblock describes: two
|
|
1726
|
+
surfaces that drift apart with nobody noticing. The story
|
|
1727
|
+
`La casella della Riga Zero porta il bordo d'accento dove, e solo dove, c'è un filtro` asserts the two SETS are
|
|
1728
|
+
equal, not their sizes, and seeds a valueless badge so that the `hasValue` predicate would fail it. ⚠️ **Only
|
|
1729
|
+
the colour changes, never the width:** a border that thickened on the filtered column would shift that cell's
|
|
1730
|
+
content by a pixel while its neighbours stayed put, which reads as misalignment rather than as a signal. The
|
|
1731
|
+
declared cost: an emptied badge no longer filters (`usableBadges` drops it) yet still lights up here — exactly
|
|
1732
|
+
as the funnel does, and disagreeing with the funnel to fix that case would be the worse defect.
|
|
1733
|
+
|
|
1734
|
+
⚠️ **Exactly ONE clear affordance per editor branch — never two, never zero — and which one it is differs by
|
|
1735
|
+
branch.** Our `[data-grid-filter-clear]` renders on `string`, `number`, `boolean` and `list_single_select`; it
|
|
1736
|
+
does **not** render on `date`/`datetime` (the `DatePicker` carries its own `clearable`) nor on
|
|
1737
|
+
`list_multi_select`. The multi-select is the branch that changed on 2026-09-11, on the owner's report that the
|
|
1738
|
+
cell showed two identical ✕: in `app-select.tsx` the multi clear sits inside `{multiple && …}` with **no**
|
|
1739
|
+
condition, so it is always there, while the single one is behind `singleClearable`, which this call-site
|
|
1740
|
+
deliberately does not pass (see limits 1 and 2 in the editor's header). Measured on the live app: the two sat at
|
|
1741
|
+
x 1126 and x 1155, and ours further **overlapped the chevron** at 1159–1175.
|
|
1742
|
+
|
|
1743
|
+
⚠️ **The two were not duplicates, and the cost of dropping ours is declared.** The primitive's clear calls
|
|
1744
|
+
`handleClear` — it empties the SELECTION, leaving the badge alive with no value, which is the state `onClear`
|
|
1745
|
+
exists to avoid (`applyFilterRowPatch` has no removal branch and `gridActiveFilterCount` keeps counting it, so
|
|
1746
|
+
the accent border stays lit on a column that no longer filters). Ours calls `onClear`, which REMOVES the filter.
|
|
1747
|
+
So on a multi-select column the cell can now empty but not remove; removal stays on the chips and in the
|
|
1748
|
+
inventory. The primitive's clear also has `tabIndex -1` (measured), so on that branch the gesture is
|
|
1749
|
+
pointer-only — limit 2, previously declared without a referent, now has one. The story `SETTE I TIPI` asserts
|
|
1750
|
+
the whole table, plus the property that matters: no branch carries two.
|
|
1751
|
+
|
|
1752
|
+
**How to mount it.** Lift the filters with `useGridFilters`, then hand the SAME three functions to both
|
|
1753
|
+
surfaces:
|
|
1754
|
+
|
|
1755
|
+
```tsx
|
|
1756
|
+
const filters = useGridFilters({
|
|
1757
|
+
columns: toGridFilterColumns(flattenGridColumns(columns)),
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
// R13 — the toggle starts OPEN, so the budget starts with the addend ON.
|
|
1761
|
+
// Seeding this to `false` gives a viewport short by `gridFilterRowHeight` px
|
|
1762
|
+
// from mount, and nothing corrects it: the first announcement only arrives on
|
|
1763
|
+
// the first click.
|
|
1764
|
+
const [filterRowOpen, setFilterRowOpen] = useState(true);
|
|
1765
|
+
|
|
1766
|
+
<AppDataGrid
|
|
1767
|
+
columns={columns}
|
|
1768
|
+
data={rows}
|
|
1769
|
+
filterMode="row"
|
|
1770
|
+
filters={filters.badges}
|
|
1771
|
+
columnVisibility={filters.columnVisibility}
|
|
1772
|
+
onAddFilter={filters.addBadge}
|
|
1773
|
+
onUpdateFilter={filters.updateBadge}
|
|
1774
|
+
onRemoveFilter={filters.removeBadge}
|
|
1775
|
+
// R13 — the toggle UNMOUNTS the row, so the budget follows it.
|
|
1776
|
+
onFilterRowOpenChange={setFilterRowOpen}
|
|
1777
|
+
viewportHeight={gridViewportHeight({
|
|
1778
|
+
rowCount: 10,
|
|
1779
|
+
density: "comfortable",
|
|
1780
|
+
hasFilterRow: filterRowOpen,
|
|
1781
|
+
})}
|
|
1782
|
+
/>;
|
|
1783
|
+
```
|
|
1784
|
+
|
|
1785
|
+
**The row is a SIBLING, not a matrix row.** `buildGridHeaderMatrix` builds `rows` with exactly
|
|
1786
|
+
`gridHeaderDepth(nodes)` entries and writes the leaves at `rows[depth - 1]`; a fourth row inside it would need
|
|
1787
|
+
a new signature, a new length and a new index in the one module that owns the `Σ colSpan === leafCount`
|
|
1788
|
+
invariant. It would also light the between-levels line on the LEAF row (`level < matrix.rows.length - 1`),
|
|
1789
|
+
i.e. a second separator next to the `<thead>`'s own shadow, which already separates the header from the body.
|
|
1790
|
+
(Until 2026-09-09 that line was a `border-b`, and this paragraph called it "invisible under
|
|
1791
|
+
`border-collapse`, with `getComputedStyle` reporting `1px` either way" — that was kinder than the truth: the
|
|
1792
|
+
border was not merely invisible, it opened a 1px hole that showed the scrolling body. See the sticky-header
|
|
1793
|
+
section above.) The zero row is therefore rendered after `<GridHeader />` and inside the `<thead>`, and
|
|
1794
|
+
`grid-header-matrix.ts` is byte-identical.
|
|
1795
|
+
|
|
1796
|
+
**Every cell sits in its own `<col>` by construction**, on three conditions the implementation keeps: exactly
|
|
1797
|
+
`(checkable ? 1 : 0) + headers.length` cells in the order of `headers`, no cell for the phantom column, and no
|
|
1798
|
+
`colSpan`. The pinned offset is `gridPinnedOffsetStyle(pinnedSlots[columnId])` — the SAME CSS variable declared
|
|
1799
|
+
once on the `<table>`, not a recomputation.
|
|
1800
|
+
|
|
1801
|
+
**Nothing declares a `top`.** The vertical stickiness comes from the `<thead>` as a BLOCK, so
|
|
1802
|
+
`getComputedStyle(tr).position` is not `sticky` and no cell carries a `top`. Only the PINNED cells are sticky,
|
|
1803
|
+
and only horizontally — the same regime as the three rows above.
|
|
1804
|
+
|
|
1805
|
+
**The toggle UNMOUNTS the row — R13, changed on 2026-09-04.** Until then it hid the CONTROLS and never the
|
|
1806
|
+
row, so the row kept its space and the first body row did not move by a pixel. Now `GridFilterRow` returns
|
|
1807
|
+
`null` while the toggle is closed: no `<tr>`, no `<td>`, no control, and no state attribute left to read —
|
|
1808
|
+
«the row is hidden» is measured as the ABSENCE of `tr[data-grid-filter-row]`. ⚠️ **The vertical jump is
|
|
1809
|
+
ACCEPTED, not a defect to repair:** `<thead>` grows and shrinks by `gridFilterRowHeight` px while the frame
|
|
1810
|
+
stays put, so the same pixel under the pointer changes command. The residue it replaces — a 36px row with
|
|
1811
|
+
nothing in it, plus a chip band standing in for it — cost more, and the button's name promises to remove the
|
|
1812
|
+
row. Reserve the space with `hasFilterRow: filterMode === "row" && open`, reading `open` from
|
|
1813
|
+
`onFilterRowOpenChange`.
|
|
1814
|
+
|
|
1815
|
+
**Nothing takes the row's place.** The band of chips that used to summarise the applied constraints while the
|
|
1816
|
+
row was hidden (`[data-grid-filter-row-band]`) has been REMOVED: it does not exist in any state of the toggle,
|
|
1817
|
+
and the toolbar toggle is the only way back — rendering it only while the row is open would leave the row
|
|
1818
|
+
unmounted for good.
|
|
1819
|
+
|
|
1820
|
+
**The constraints scrolled out of the horizontal viewport are NOT named, and reaching them is scrolling —
|
|
1821
|
+
`adg-r14`, 2026-09-04.** The «N filters out of view» button that used to name them in the toolbar has been
|
|
1822
|
+
removed together with its geometry measurement, its reveal arithmetic and its two i18n keys. This is a
|
|
1823
|
+
WITHDRAWAL and not an oversight: criterion 7 of Phase 9 is revoked in both halves (R13 took the chip band, this
|
|
1824
|
+
one takes the indicator), and the reason is toolbar width — the button cost 110px on a row that is already
|
|
1825
|
+
over budget. Deviation 37 of `docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md` carries the revocation with
|
|
1826
|
+
its measurements. The mockup does have such an indicator, but as an overlay pinned to the right edge of the
|
|
1827
|
+
filter row rather than a toolbar control; deciding whether to build it there belongs to whoever owns the
|
|
1828
|
+
vertical-space budget, not to this document.
|
|
1829
|
+
|
|
1830
|
+
**Both roads produce the same state, by construction and not by convention.** A cell creates its badge with
|
|
1831
|
+
`createGridFilterBadge`, the one and only constructor, and then applies the patch; `onAddFilter`/`onUpdateFilter`
|
|
1832
|
+
are the same two functions the inventory panel receives. The operators a cell offers come from
|
|
1833
|
+
`gridColumnFilterControl`, the same single derivation.
|
|
1834
|
+
|
|
1835
|
+
**One threshold, measured.** Under `gridFilterRowShortPlaceholderMaxWidth` (112, strict) the resting text
|
|
1836
|
+
takes the SHORT form of the placeholder — an exception PER TYPE, today only `number`, so a narrow text column
|
|
1837
|
+
keeps the full form — while the accessible name names the operator IN FULL in both branches.
|
|
1838
|
+
|
|
1839
|
+
**No glyph marks the truncation, at any width.** The text span carries `truncate`, so the ellipsis is drawn by
|
|
1840
|
+
the browser (`text-overflow: ellipsis`); a glyph beside it would print a SECOND one right after the clipped
|
|
1841
|
+
tail, which is why the former truncation icon was removed rather than replaced. The three channels that announce the
|
|
1842
|
+
cut — the always-present `data-filter-truncated`, the `title`, and the `sr-only` node — carry the whole value at
|
|
1843
|
+
any width, and none of the three is chromatic. Truncation is measured on the EFFECT
|
|
1844
|
+
(`scrollWidth > clientWidth`), never estimated from a character width.
|
|
1845
|
+
|
|
1846
|
+
### Row grouping (P9)
|
|
1847
|
+
|
|
1848
|
+
`groupBy` groups rows by one or more columns, in order. **Client only.**
|
|
1849
|
+
|
|
1850
|
+
```tsx
|
|
1851
|
+
const columns = [
|
|
1852
|
+
createGridStringColumn<Row>({ accessorKey: "country", header: "Country" }),
|
|
1853
|
+
createGridStringColumn<Row>({ accessorKey: "city", header: "City" }),
|
|
1854
|
+
createGridNumberColumn<Row>({
|
|
1855
|
+
accessorKey: "score",
|
|
1856
|
+
header: "Score",
|
|
1857
|
+
// Receives EVERY row under the group, at any depth — a first-level total has
|
|
1858
|
+
// to see the sub-groups' rows too, not just the ones directly attached.
|
|
1859
|
+
aggregatedCell: (rows) => rows.reduce((a, r) => a + r.score, 0),
|
|
1860
|
+
}),
|
|
1861
|
+
];
|
|
1862
|
+
|
|
1863
|
+
<AppDataGrid columns={columns} data={rows} groupBy={["country", "city"]} />;
|
|
1864
|
+
```
|
|
1865
|
+
|
|
1866
|
+
**Client only, and the compiler says so — not the docs.** `AppDataGridProps<TData>` is a union of
|
|
1867
|
+
`GridClientProps` (has `groupBy`, `onQueryChange?: never`) and `GridServerProps` (has
|
|
1868
|
+
`onQueryChange`, `groupBy?: never`). Passing both does not compile. The property is verified by
|
|
1869
|
+
`node scripts/check-grouping-server-unrepresentable.mjs`, which runs `tsc` over
|
|
1870
|
+
`type-probes/grouping-server.probe.tsx` and **requires it to fail** — a probe file that compiled
|
|
1871
|
+
would be the news. Widen the type and that gate goes red.
|
|
1872
|
+
|
|
1873
|
+
Why there is no server-side grouping: a group has to count its own rows and hand them to
|
|
1874
|
+
`aggregatedCell`. With a server source the grid holds **one page**, so "12 rows" and a column total
|
|
1875
|
+
would be the count of what happened to arrive — a number that looks exactly like a total and is not
|
|
1876
|
+
one. The place to aggregate a paged source is the server, and what it sends back are not rows to
|
|
1877
|
+
group: they are already groups.
|
|
1878
|
+
|
|
1879
|
+
#### The invariants the phase rests on
|
|
1880
|
+
|
|
1881
|
+
- **No body cell that carries data spans more than one column** — group rows included, and from
|
|
1882
|
+
Fase 5 with **two named exceptions**. A group row has exactly one cell per visible leaf column, and
|
|
1883
|
+
the cells with nothing to say are **empty**, which is different information from "this column is
|
|
1884
|
+
not in the group". The usual way to write a group row — one cell as wide as the table — is the
|
|
1885
|
+
forbidden one: the `id`/`headers` chain from P5 is derived by walking the header spans, so a body
|
|
1886
|
+
cell declaring its own would skew that derivation for every row below, silently, because the table
|
|
1887
|
+
keeps rendering. Watched on the DOM by `NessunaCellaDiCorpoSiEstende`, which measures
|
|
1888
|
+
`cell.colSpan` rather than the source text — and those specs stay green because they run on scenes
|
|
1889
|
+
that HAVE rows, where neither exception is rendered.
|
|
1890
|
+
|
|
1891
|
+
**The two exceptions, and why they do not skew the chain.** Fase 5 admits a horizontal extension on
|
|
1892
|
+
exactly two nodes, both in `parts/grid-table.tsx`: the **status cell** (`[data-grid-status-cell]` /
|
|
1893
|
+
`[data-grid-empty]`), which is the only cell of its row and exists only when the body has NO data
|
|
1894
|
+
rows — so there is no row below it whose chain could be skewed — and the **footer label cell**
|
|
1895
|
+
(`[data-grid-footer-label]`), reserved and **not used today**: the `<tfoot>` renders one `<td>` per
|
|
1896
|
+
column, label included, because an extended label would cost every pinned column inside the
|
|
1897
|
+
extension its own footer cell and with it the left/right comparison against the body. The perimeter
|
|
1898
|
+
is held mechanically by `scripts/check-grid-colspan-perimeter.mjs`, which works on the AST (the
|
|
1899
|
+
token also appears in a comment and in an error string, where a grep would count it), admits the two
|
|
1900
|
+
markers only in that file, requires at least ONE admitted extension in total — zero would make the
|
|
1901
|
+
gate vacuous — counts the `<thead>` extensions separately as the R12 MECHANISM and goes red if they
|
|
1902
|
+
drop to zero, and asserts that the token appears ZERO times in `parts/grid-row.tsx`, where P9's
|
|
1903
|
+
criterion is a grep and the term is absent by design.
|
|
1904
|
+
|
|
1905
|
+
- **Group rows are exactly as tall as data rows.** That keeps the total height a product of
|
|
1906
|
+
constants, which is what P3's verdict requires (never `measureElement`). A level reads from the
|
|
1907
|
+
**indent** and the text weight, not from the height.
|
|
1908
|
+
- **A group's key is its path**, joined with U+001F — not the value, or `["country","city"]` would
|
|
1909
|
+
merge the French Nice with the Spanish one and opening either would open both.
|
|
1910
|
+
- **Expansion state records the exceptions**: the map holds `false` for collapsed groups and nothing
|
|
1911
|
+
else, so a group nobody touched is **open**. The other way round, a freshly mounted grid would
|
|
1912
|
+
show no data at all.
|
|
1913
|
+
- **Expanding is a view action.** `view/group-expansion` has no path to the emitter, so "expanding
|
|
1914
|
+
does not refetch" is unrepresentable rather than merely unimplemented.
|
|
1915
|
+
|
|
1916
|
+
#### Scroll compensation, and its declared limit
|
|
1917
|
+
|
|
1918
|
+
Opening a group that sits **above the viewport** inserts rows above what you are reading, so
|
|
1919
|
+
everything slides down. The grid raises `scrollTop` by exactly the height that appeared, in a layout
|
|
1920
|
+
effect, before the browser paints.
|
|
1921
|
+
|
|
1922
|
+
How many rows appear is **not** the group's `leafCount`: it depends on which descendants are
|
|
1923
|
+
themselves open, and each keeps its own state. It is measured by flattening with the next state and
|
|
1924
|
+
subtracting the lengths — exact by construction rather than by a formula to keep in sync.
|
|
1925
|
+
|
|
1926
|
+
⚠️ **Compensation only applies when the expanded group is above the viewport**, and that is a limit,
|
|
1927
|
+
not an oversight: if the group is **visible**, the new rows appear between it and whatever you are
|
|
1928
|
+
reading further down, and there is no `scrollTop` that holds both still — keeping the lower row fixed
|
|
1929
|
+
would push the just-opened group off the screen, which is the opposite of what the gesture asked for.
|
|
1930
|
+
|
|
1931
|
+
⚠️ **It needs a BOUNDED viewport — and from Fase 6 a grouped grid always has one.**
|
|
1932
|
+
Compensation corrects the scroll container's `scrollTop`, so it does nothing at all when the grid has
|
|
1933
|
+
no bounded height. **This paragraph used to describe a hole and now describes the fix**: P9 wrote that
|
|
1934
|
+
`<AppDataGrid columns={columns} data={rows} groupBy={["country"]} />` — a legitimate and minimal
|
|
1935
|
+
call-site — grew with its content, let **the page** scroll, kept `viewport.scrollTop` at `0`, and
|
|
1936
|
+
therefore expanded and collapsed with no compensation whatsoever. Fase 6 added `groupBy` as the fourth
|
|
1937
|
+
term of `viewportIsBounded` (`app-data-grid.tsx`), so that call-site now takes the default
|
|
1938
|
+
`viewportHeight` of `480`, scrolls its own container, and compensates.
|
|
1939
|
+
|
|
1940
|
+
The reason came from a different criterion and is worth writing down, because it is what makes the
|
|
1941
|
+
change more than a convenience: "changing density never shows FEWER rows" is only measurable **inside a
|
|
1942
|
+
frame**. A grid that grows with its content has no frame, so that promise is vacuously true on it.
|
|
1943
|
+
|
|
1944
|
+
⚠️ **The second reason this paragraph used to give has been RETIRED, and it is named here rather than
|
|
1945
|
+
quietly dropped.** It read: _and the same is true of "the selection bar recomposes the height instead
|
|
1946
|
+
of pushing the content down"_. On **2026-09-03** the bar moved **above** the scroll area, to the
|
|
1947
|
+
mockup's order, so the table band **does** get pushed down — by exactly one frame addend
|
|
1948
|
+
(`gridRowHeight[density] + gridFrameGap`). What survives, and is what criterion 7 actually measures, is
|
|
1949
|
+
that the **viewport does not shrink** and no data row is lost: the FRAME grows, the window does not
|
|
1950
|
+
narrow. `viewportIsBounded` keeps `groupBy` for the **first** reason above, which is untouched — so the
|
|
1951
|
+
retirement is not a veto on that change.
|
|
1952
|
+
|
|
1953
|
+
⚠️ **What this changes for existing grouped call-sites**: one that passed `groupBy` and no
|
|
1954
|
+
`viewportHeight` moves from "as tall as its content, the page scrolls" to "480px, the container
|
|
1955
|
+
scrolls" — with a sticky header and Radix's scrollbar lanes appearing. Pass an explicit
|
|
1956
|
+
`viewportHeight` to choose a different frame. Compensating the page instead of the container remains a
|
|
1957
|
+
different feature, not a bug fix.
|
|
1958
|
+
|
|
1959
|
+
#### Declared limits
|
|
1960
|
+
|
|
1961
|
+
- **Closing a group you are reading _inside_ lands you on that group's row.** Compensation has two
|
|
1962
|
+
named modes, because these are two different situations for the reader and not two branches of one
|
|
1963
|
+
`if`. `sopra` — the group row is above the viewport — is not enough on its own: the group's
|
|
1964
|
+
_descendants_ can cross the viewport, so closing may remove the very rows you were looking at. The
|
|
1965
|
+
discriminator is arithmetic, from the row count and never from a measurement: closing removes
|
|
1966
|
+
`k = −delta` rows occupying indices `indice+1 … indice+k`, and you are reading _inside_ when
|
|
1967
|
+
`primaVisibile ≤ indice + k`.
|
|
1968
|
+
- `tieni-ferma-la-vista` — everything that disappears is above what you are reading. The row under
|
|
1969
|
+
your eyes still exists afterwards, so it is held still: target `scrollTopPrima + delta × rowHeight`.
|
|
1970
|
+
- `atterra-sul-gruppo` — you were reading inside. The rows under your eyes are gone, so "hold the
|
|
1971
|
+
observed row still" has no referent and the arithmetic that attempts it runs below zero (measured:
|
|
1972
|
+
target −1296, browser clamped to 0, i.e. back to the top of the list). Instead the target is
|
|
1973
|
+
`indice × rowHeight`: you land on the group row, the object you just acted on. Measured on the
|
|
1974
|
+
two-level dataset, closing a mid-list group from inside: old target 684, browser clamp 0, actual
|
|
1975
|
+
**1440** — the group's own row, flush under the sticky header.
|
|
1976
|
+
|
|
1977
|
+
Both modes are pinned by specs, and each spec fails under the other mode's fault.
|
|
1978
|
+
|
|
1979
|
+
The target can never overshoot in the other direction. The browser keeps
|
|
1980
|
+
`scrollTopPrima ≤ bottomBefore`, and closing `k` rows lowers the bottom by exactly `k × rowHeight`,
|
|
1981
|
+
so `target = scrollTopPrima − k·h ≤ bottomBefore − k·h = bottomAfter`. Measured across four
|
|
1982
|
+
scenarios; `target > bottomAfter` did not occur in any.
|
|
1983
|
+
|
|
1984
|
+
**This used to be a defect and is no longer one.** The compensation was **relative**
|
|
1985
|
+
(`scrollTop += delta × rowHeight`) inside a layout effect, which runs after React has already
|
|
1986
|
+
mutated the DOM — and the browser clamps `scrollTop` at the instant of that mutation. Opening never
|
|
1987
|
+
showed it (the list only grows, nothing to clamp); closing shrank the list first, so the `+=`
|
|
1988
|
+
subtracted the delta from an already-clamped value, a second time. Measured on a 40-group dataset
|
|
1989
|
+
at `scrollTop` 900: expected 900 — reachable, the shortened list bottoms out at 992 — actual
|
|
1990
|
+
**344**, 556px adrift, over fifteen rows. No layout hook can observe an untruncated `scrollTop`, so
|
|
1991
|
+
no relative version of that code was correct; the absolute target is immune by construction.
|
|
1992
|
+
|
|
1993
|
+
- **One height for every row.** Group rows could legitimately have their own — the criterion allows
|
|
1994
|
+
it, provided the value is exact per index — but that would cost a variable-pitch virtualizer to buy
|
|
1995
|
+
a few pixels of breathing room.
|
|
1996
|
+
- **Groups are ordered by first appearance**, not alphabetically: the rows arrive already sorted by
|
|
1997
|
+
whoever clicked a header, and reordering the groups independently would ignore that sort.
|
|
1998
|
+
- **Hiding the grouping column does not dismantle the groups.** Grouping is a property of the
|
|
1999
|
+
_data_, not of what is rendered, so the values are read from the original row rather than through
|
|
2000
|
+
the (already pruned) column registry — and the label, count and disclosure control move to the
|
|
2001
|
+
first visible column. Hiding a column is a view choice; it must not take away the only way to
|
|
2002
|
+
navigate the groups that same view still shows.
|
|
2003
|
+
- **A `groupBy` entry naming a column that does not exist is dropped**, not rendered as an anonymous
|
|
2004
|
+
group. A typo used to produce one label-less, count-less, control-less row that swallowed every
|
|
2005
|
+
data row underneath it — a state the user could not get out of.
|
|
2006
|
+
- **`paginated` and `groupBy` are mutually exclusive in the type**, for the same reason server
|
|
2007
|
+
grouping is: see the `paginated` row in the props table.
|
|
2008
|
+
- **`viewportHeight` now applies whenever it is passed explicitly**, not only under `virtualized` /
|
|
2009
|
+
`infinite`. Three special cases for the same property were an list that kept growing; the honest
|
|
2010
|
+
rule is that a call-site asking for a bounded viewport gets one.
|
|
2011
|
+
|
|
2012
|
+
### Page selection: the bar, the status line and the group tri-state (Fase 6)
|
|
2013
|
+
|
|
2014
|
+
**The selection is page-only, and that is a signed decision, not a limitation waiting to be lifted.**
|
|
2015
|
+
There is no "select all 128 rows" link and no promotion to result scope: `GridSelectionSlots["mode"]`
|
|
2016
|
+
is a union with a single member so that adding one would be a compile error. The header checkbox names
|
|
2017
|
+
that scope in **both** bags — `selectAll` is «Select all rows on this page» / «Seleziona tutte le righe
|
|
2018
|
+
della pagina» — because a checkbox that promises more than `toggleAllRows` performs is the
|
|
2019
|
+
"128 records deleted believing them 10" failure.
|
|
2020
|
+
|
|
2021
|
+
**The bar does not exist while nothing is ticked.** Not "renders with height 0": the frame is a
|
|
2022
|
+
`flex flex-col gap-2`, so an always-rendered wrapper costs 8px of gap even when it measures zero —
|
|
2023
|
+
one data row, for ever. It is a **sibling of the scroll area**, in flow, outside the `<table>`: inside
|
|
2024
|
+
the viewport it would eat a row without the height formula knowing, and `position: absolute` would
|
|
2025
|
+
paint over the content instead of recomposing it. **With a bounded frame** the frame grows and the
|
|
2026
|
+
viewport does not shrink — see the declared limit below, which is where that stops being true.
|
|
2027
|
+
|
|
2028
|
+
⚠️ **AND IT SITS ABOVE THE SCROLL AREA, NOT UNDER IT — changed on 2026-09-03.** The order is
|
|
2029
|
+
toolbar → filter band → **selection bar** → table band, which is the mockup's
|
|
2030
|
+
(`AppDataGrid.mockup.stories.tsx` renders `CompleteSelectionBar` between `CompleteFilterSummary` and
|
|
2031
|
+
the table band). The two failure modes named just above are both **internal** to the viewport, so the
|
|
2032
|
+
signed reason of 2026-09-02 establishes that the bar must live **outside** the viewport — it never
|
|
2033
|
+
established that it must live **under** it; the two questions had been fused into one. Above or below
|
|
2034
|
+
is only the line the tag is written on: there is no `order-*`, no `flex-col-reverse`, no `position`,
|
|
2035
|
+
no `mt-auto`, and `gridFrameHeight` is blind to order because one more flow child is one more gap
|
|
2036
|
+
either way.
|
|
2037
|
+
|
|
2038
|
+
⚠️ **THE PRICE, AND IT IS A PROMISE WITHDRAWN.** "The selection bar recomposes the height instead of
|
|
2039
|
+
pushing the content down" is **no longer true in its second half**: with the bar above, the table band
|
|
2040
|
+
**translates down** by exactly `gridRowHeight[density] + gridFrameGap`. Measured in the
|
|
2041
|
+
`BarraSopraLaBandaTabella` scene at `comfortable` density: the bar's `top` is 125 and the `<table>`'s
|
|
2042
|
+
is 181, a 56px gap that is 48 + 8. What is kept is the half criterion 7 measures — `viewport.clientHeight`
|
|
2043
|
+
unchanged and no visible data row lost — and `BarraCompareERicompone` now asserts the **translation to
|
|
2044
|
+
the pixel** instead of the first row's immobility, which is a strictly stronger assertion: immobility
|
|
2045
|
+
was green both when the bar recomposed and when it painted over the content from below.
|
|
2046
|
+
|
|
2047
|
+
⚠️ **The mockup's `role="status"` did NOT come along.** `CompleteSelectionBar` is
|
|
2048
|
+
`role="status" aria-live="polite" aria-atomic="true"`; the bar is `role="group"` + `aria-labelledby`,
|
|
2049
|
+
and that stays. "The mockup becomes the baseline" applies to the **position**, not to the role: a
|
|
2050
|
+
second live region would announce twice, not more, and `UnaSolaRegioneLive` counts
|
|
2051
|
+
`[role="status"],[role="alert"],[aria-live]` per grid precisely to catch it.
|
|
2052
|
+
|
|
2053
|
+
**Two height formulas, because they measure two different boxes.** ⚠️ **Both are internal to the
|
|
2054
|
+
library — they are not exported from the package**, so a consumer cannot call them; they are described
|
|
2055
|
+
here to explain how the grid sizes itself, not as an API to import.
|
|
2056
|
+
`gridViewportHeight({rowCount, density, hasFooter, hasFilterRow, headerLevels})` returns the
|
|
2057
|
+
**scroll box** — that is the number the `viewportHeight` prop wants.
|
|
2058
|
+
`gridFrameHeight({...the same five, plus hasSelectionBar})` returns the viewport **plus the in-flow
|
|
2059
|
+
bands beside it**, `gridFrameGap` included, because when the bar appears the frame gains one flow
|
|
2060
|
+
child and therefore one more gap. ⚠️ **«The same five» is load-bearing and used to be a lie in the
|
|
2061
|
+
code**: `GridFrameHeightInput` INHERITS the viewport's fields, but the function destructured only
|
|
2062
|
+
three of them and forwarded only those — so whoever passed `hasFilterRow` (and, later,
|
|
2063
|
+
`headerLevels`) to the frame believed they had lit the addend and had not, with no error anywhere.
|
|
2064
|
+
`adg-m1` repaired it in the same commit that added `headerLevels`, for exactly this reason: an
|
|
2065
|
+
inherited field that is silently dropped is the same defect twice.
|
|
2066
|
+
|
|
2067
|
+
⚠️ **From `adg-m3` there is a THIRD option, and for a stretched layout it is the right one: pass nothing.**
|
|
2068
|
+
An unbounded viewport now carries `min-h-0 grow`, so inside a parent that HAS a definite height the scroll
|
|
2069
|
+
area takes the leftover by itself and no formula has to predict it — which is the case the two formulas
|
|
2070
|
+
could never serve well, because the caller would have to know the frame's pixels before layout. The formulas
|
|
2071
|
+
stay the answer for the opposite case: a grid that must DECLARE its own height (`virtualized`, `infinite`,
|
|
2072
|
+
`groupBy`, or any bounded box), where `viewportHeight` is not optional.
|
|
2073
|
+
|
|
2074
|
+
⚠️ **THE SIGNATURE ABOVE USED TO OMIT `hasFilterRow` AND `headerLevels`, AND THAT OMISSION WAS THE
|
|
2075
|
+
MOST DANGEROUS LINE IN THIS FILE.** The remedy for a short viewport lives **entirely in the caller's
|
|
2076
|
+
hand**: the component never calls `gridViewportHeight` — `viewportHeight` is a prop that whoever
|
|
2077
|
+
mounts the grid computes. So anyone writing the next call site from that truncated signature
|
|
2078
|
+
**reproduces the 64px defect**, and no gate stops them: `scripts/check-grid-props-documented.mjs`
|
|
2079
|
+
reads `app-data-grid.types.ts`, while `GridViewportHeightInput` lives in `grid-tokens.ts`. The
|
|
2080
|
+
defect's mode of birth was silence, and a truncated signature here is how it comes back. Verified
|
|
2081
|
+
against the source destructuring, not against a report of it.
|
|
2082
|
+
|
|
2083
|
+
⚠️ **`headerLevels` is derived, never a literal:** `gridHeaderDepth(columns)` — the same function
|
|
2084
|
+
`buildGridHeaderMatrix` uses to decide how many `<tr>` to render, so the number of levels the
|
|
2085
|
+
formula reserves and the number the header renders cannot drift apart. It is optional and defaults
|
|
2086
|
+
to `1`. **It does NOT cover the Zero Row**: that row is a fourth `<tr>` but its height is a separate
|
|
2087
|
+
addend, `hasFilterRow`, because it is `gridFilterRowHeight` and not `headerHeight`. Passing
|
|
2088
|
+
`headerLevels: 4` to account for it would reserve the wrong number of pixels.
|
|
2089
|
+
|
|
2090
|
+
⚠️ **`gridFrameHeight` is not an absolute budget.** It excludes the root's `p-2` and `border`, the
|
|
2091
|
+
toolbar, and the paginator. Measured: in the `BarraCompareERicompone` scene the root renders **604px**
|
|
2092
|
+
while the function returns **522** — the missing 82 are border 1×2 + padding 8×2 + toolbar 56 + one gap.
|
|
2093
|
+
Reserving 522px in a fixed-height panel would clip the grid. Use it for the **delta** the band costs,
|
|
2094
|
+
not as the component's height.
|
|
2095
|
+
|
|
2096
|
+
⚠️ **They used to be one function, and the single signature was the defect.**
|
|
2097
|
+
`gridViewportHeight` accepted `hasSelectionBar` and in that case stopped returning a viewport — the
|
|
2098
|
+
name promised the window and the value was something else, while the call sites passed the result
|
|
2099
|
+
straight to the `viewportHeight` prop. Anyone lighting the flag would have **shortened** the scroll box
|
|
2100
|
+
believing they were enlarging the frame. It was also 8px short, since the gap was never counted. The
|
|
2101
|
+
split makes the mistake **fail to compile**, for literals _and_ for already-typed values:
|
|
2102
|
+
`hasSelectionBar` lives on `GridFrameHeightInput` and is pinned to `?: never` on
|
|
2103
|
+
`GridViewportHeightInput`, so passing the wider type to the narrower function is an error rather than a
|
|
2104
|
+
silently dropped field.
|
|
2105
|
+
|
|
2106
|
+
⚠️ **Declared limit — in fullscreen the band is paid by the viewport, and one data row can go.**
|
|
2107
|
+
This is a **product decision taken on 2026-09-02**, not an open defect, and **moving the bar above on
|
|
2108
|
+
2026-09-03 does not touch it**. With a bounded frame the root is as tall as its content, so the bar
|
|
2109
|
+
makes the **frame** grow and the visible data row COUNT stays put — that is where criterion 7 was
|
|
2110
|
+
written, and it holds there (the table band does translate down; the window does not narrow). In fullscreen the root is `fixed inset-0`:
|
|
2111
|
+
the frame **is** the window and cannot grow by a pixel, so the band is paid by the only elastic child,
|
|
2112
|
+
the scroll area (`min-h-0 flex-1`). Measured: `viewport.clientHeight` **820 → 776** and visible data
|
|
2113
|
+
rows **22 → 21** at `default` density (36px band + 8px gap).
|
|
2114
|
+
|
|
2115
|
+
⚠️ **`adg-m3` opens a THIRD case, and it is DERIVED from the flex rules rather than measured in a browser:**
|
|
2116
|
+
outside fullscreen, a grid with no `viewportHeight` whose PARENT constrains its height (the consumer's
|
|
2117
|
+
`className="flex min-h-0 flex-1"`) now has an elastic scroll area too (`min-h-0 grow`), so there the band is
|
|
2118
|
+
paid by the viewport exactly as in fullscreen instead of making the frame grow. In a scene whose parent has
|
|
2119
|
+
no definite height — every story of the lane that omits the prop — `flex-basis: auto` keeps the root growing
|
|
2120
|
+
with its content, so the sentence above and criterion 7 hold unchanged. Nobody has re-measured the row count
|
|
2121
|
+
in the constrained case; it is stated as a consequence of the classes, not as a number.
|
|
2122
|
+
|
|
2123
|
+
The two rejected alternatives, each for its own reason. **Overlaying** the bar in fullscreen loses no
|
|
2124
|
+
row from the count but **covers** the last one while the selection is active — criterion 7 would stay
|
|
2125
|
+
green while saying something false — and it is a second geometry of the bar to keep aligned with the
|
|
2126
|
+
first. **Always reserving** the band moves nothing, but contradicts criterion 1 ("at zero selection it
|
|
2127
|
+
occupies **no** height") and leaves an empty band at rest for the grid's whole life.
|
|
2128
|
+
|
|
2129
|
+
The number is pinned by the `BarraASchermoIntero` spec, so it cannot get worse in silence: it asserts
|
|
2130
|
+
that in fullscreen the frame does **not** grow while the viewport shrinks by **exactly** the band plus
|
|
2131
|
+
the gap, and that unticking returns it to the pixel. The spec reads the delta from the tokens instead
|
|
2132
|
+
of hard-coding `776`, which would be green or red depending on the host's window.
|
|
2133
|
+
|
|
2134
|
+
**The bar computes nothing.** Its count is `engine.selectedPageRowIds.length` — the ticked ids the
|
|
2135
|
+
current row model still knows — and its totals are `engine.selectionCells`, i.e.
|
|
2136
|
+
`aggregateCells(selectedPageRowIds)`, which reaches `applyGridAggregate` through the same
|
|
2137
|
+
`cellsForRows` as the `<tfoot>`. With the whole page ticked, bar and footer read the same characters by
|
|
2138
|
+
construction rather than while two formulas agree.
|
|
2139
|
+
|
|
2140
|
+
**Where "0 rows selected" is said.** Nowhere on screen — the bar is absent — so it is said by the
|
|
2141
|
+
**single live region**, which from this phase composes **four clauses in one string**: sort priority
|
|
2142
|
+
(from the same `sortIndex`/`sortCount` that name the header buttons), the row count (from
|
|
2143
|
+
`gridResultScope.totalLabel`, the single source — never the bare `total`, which would print `1284`
|
|
2144
|
+
where the paginator shows `1.284`), active filters, and the selection. One string and one text node,
|
|
2145
|
+
with `aria-atomic="true"`: four `<span>`s would pass four `toContain` while a screen reader read the
|
|
2146
|
+
sentence in pieces. The bar carries `role="group"` + `aria-labelledby` — **never a second
|
|
2147
|
+
`role="status"`**.
|
|
2148
|
+
|
|
2149
|
+
⚠️ **A defect this phase found and fixed while wiring that string.** The live region's
|
|
2150
|
+
"more rows loaded" branch was guarded only by `requestCount > 1`, and `requestCount` is **seeded at
|
|
2151
|
+
mount** with `Math.ceil(data.length / pageSize)` — 13 for 128 rows on pages of 10. ⚠️ **WHICH `pageSize`,
|
|
2152
|
+
made explicit on 2026-09-09**: the LIVE one, `initialState?.pagination?.pageSize ?? pageSize`, the same
|
|
2153
|
+
expression the state slice uses. Until then the seed divided by the `pageSize` **prop** alone while the
|
|
2154
|
+
slice used the live value, so a grid passing `initialState.pagination.pageSize` different from the prop
|
|
2155
|
+
seeded a counter on the wrong divisor and the first emitted `pageIndex` skipped or repeated pages. Nailed
|
|
2156
|
+
by `app-data-grid-fix-motore.stories.tsx :: IlSemeDelContatoreUsaLaTagliaViva`. So any grid with more
|
|
2157
|
+
than one page of data announced «More rows loaded» at rest, to someone who had loaded nothing. The
|
|
2158
|
+
branch is now guarded by `infinite !== undefined` as well, the same guard already declared for
|
|
2159
|
+
appended skeletons.
|
|
2160
|
+
|
|
2161
|
+
**The group row's checkbox works on a collapsed group**, which is the whole point: the leaves of a
|
|
2162
|
+
closed group are not in `bodyRows` and have no DOM node, so nothing can be derived from the rendered
|
|
2163
|
+
rows. `GridGroupRowView.leafIds` is filled on the tree **above** the flattener's
|
|
2164
|
+
`if (!expanded) continue`, descending into `children` **before** reading `leaves` (which only exists at
|
|
2165
|
+
the last level of `groupBy`), and indexed by `path` — never by label, which collides between namesakes
|
|
2166
|
+
under different parents. The tick then goes through **`engine.toggleRows(leafIds, selected)`**, one
|
|
2167
|
+
mutation: N `toggleRow` calls in the same tick each copy the map from the closure, so only one
|
|
2168
|
+
survives, and a 50-row group would end up selected as 1 while the header checkbox still showed
|
|
2169
|
+
`indeterminate` — green for the wrong reason.
|
|
2170
|
+
|
|
2171
|
+
**New i18n keys** (all optional in `GridI18n`, all required in `defaultGridI18n`, so the per-leaf
|
|
2172
|
+
fallback always exists): `selection.selectedOne` / `selectedMany` / `outOf` / `onPage` / `dismiss` /
|
|
2173
|
+
`deselectAll`, `status.sortedBy` / `filterActiveOne` / `filterActiveMany`, and
|
|
2174
|
+
`grouping.selectGroup`. `outOf` is **not** `pagination.of`: «Rows 1-10 **of** 128» and «2 rows selected
|
|
2175
|
+
**of** 10» are one word in English and two in Italian («di» / «su»). Unit and participle live in a
|
|
2176
|
+
single key for the same reason. One changed value: `selectAll` (see above).
|
|
2177
|
+
|
|
2178
|
+
### Selected row: tint and left bar (R19)
|
|
2179
|
+
|
|
2180
|
+
A selected row is painted with an **accent tint** plus a **vertical bar on the row's left edge**. Both come
|
|
2181
|
+
from tokens in `grid-tokens.ts` — `gridSelectedRowTint`, `gridSelectedRowBarShadow`,
|
|
2182
|
+
`gridSelectedRowBarWidth` — and both reach the DOM as **inline styles**, never as utility classes.
|
|
2183
|
+
|
|
2184
|
+
⚠️ **The defect this replaced was a single colour.** Hover and selection both resolved to the same muted
|
|
2185
|
+
surface — measured `rgb(245, 245, 245)` in the light theme — so a row under the pointer was indistinguishable
|
|
2186
|
+
from a checked one. A state and a transit painted alike; the checkbox was the only witness of selection.
|
|
2187
|
+
|
|
2188
|
+
⚠️ **The tint is a mix, not an opacity.** «Low-opacity accent» describes the look, not the implementation: a
|
|
2189
|
+
semi-transparent background on a **pinned** cell would let the columns scrolling underneath show through.
|
|
2190
|
+
`gridSelectedRowTint` is `color-mix(in srgb, var(--d-primary) 24%, var(--d-background))` — the same appearance
|
|
2191
|
+
with an opaque colour. Consumers who re-theme `--d-primary` or `--d-background` re-theme the tint with it.
|
|
2192
|
+
|
|
2193
|
+
⚠️ **The tint is on every cell, not only on the `<tr>`.** A background declared on the row alone shows through
|
|
2194
|
+
the transparent cells and **not** through the pinned ones, which are opaque by necessity: the selected row
|
|
2195
|
+
came out tinted only in the scrolling columns, cut exactly on the pinning boundary. With the tint on every
|
|
2196
|
+
cell, the computed `backgroundColor` of the checkbox cell, of a pinned cell and of a scrolling cell of the
|
|
2197
|
+
same row is the **same value**.
|
|
2198
|
+
|
|
2199
|
+
⚠️ **The bar is a `box-shadow` layer, and it is composed — never assigned on its own.** Under
|
|
2200
|
+
`border-collapse: collapse` a declared border belongs to the *table's* border grid, so on a sticky pinned cell
|
|
2201
|
+
it would scroll away with the body; and a pseudo-element would need new utility classes, i.e. a value in the
|
|
2202
|
+
scales of two Tailwind majors. The layer is composed by `gridPinnedOffsetStyle` together with the pinned-edge
|
|
2203
|
+
shadow of R20 and the header group separator: with a one-column pinned block and no checkbox, the row's first
|
|
2204
|
+
cell **is** the block's boundary, and two `box-shadow` assignments would silently overwrite each other.
|
|
2205
|
+
|
|
2206
|
+
⚠️ **The bar sits on the row's left edge, and never on the same strip as a boundary.** On a scrolled grid it
|
|
2207
|
+
stays at the viewport's visible left edge, because it rides the first pinned cell.
|
|
2208
|
+
|
|
2209
|
+
⚠️ **Where the two signals collide, the boundary wins and the bar is not emitted — a declared refusal.**
|
|
2210
|
+
`gridPinnedRightEdgeShadow` is derived from the left one by dropping the minus sign, so **both** its layers
|
|
2211
|
+
have a **positive** offset, i.e. they paint on the box's **left** side — the same side as the bar. When a row
|
|
2212
|
+
has no checkbox column and its **first** declared column is `pinned: "right"`, that first cell is both the
|
|
2213
|
+
start of the row and the right block's boundary: the bar's 3 opaque px covered the boundary's 1px hairline
|
|
2214
|
+
entirely, and which signal you saw was decided by the order of a `push`. `gridPinnedOffsetStyle` now emits the
|
|
2215
|
+
bar **only when no other layer occupies the box's left side** (the right-block boundary, or the header group
|
|
2216
|
+
separator). The boundary keeps the pixel because it states a fact about the *table* — where content starts
|
|
2217
|
+
scrolling — while selection stays readable regardless: the tint is on every cell and does not depend on the
|
|
2218
|
+
bar. Measured by `BarrettaSenzaSpuntePinnataADestra`; the non-colliding case, where the same cell is both the
|
|
2219
|
+
row's first and the **left** block's boundary, keeps both signals and is measured by its twin.
|
|
2220
|
+
|
|
2221
|
+
⚠️ **Both themes are measured, and the dark one is the half that can fail.** `--d-primary` is `#64c2d1` in
|
|
2222
|
+
both; the backgrounds diverge. Measured at 24%:
|
|
2223
|
+
|
|
2224
|
+
| theme | rest background | hover | selected tint | max channel gap vs hover | chroma (B−R) |
|
|
2225
|
+
| ----- | --------------- | ----- | ------------- | ------------------------ | ------------ |
|
|
2226
|
+
| light | `rgb(245,245,245)` | `rgb(245,245,245)` | `rgb(210,233,236)` | 35 | 26 |
|
|
2227
|
+
| dark | `rgb(20,20,20)` | `rgb(46,46,46)` | `rgb(39,62,65)` | 19 | 26 |
|
|
2228
|
+
|
|
2229
|
+
In the dark theme the lightness gap against hover is small; what keeps the tint readable there is **chroma** —
|
|
2230
|
+
it is blue-ish where hover is grey. At 12% the dark tint would have been `rgb(30,41,43)`, i.e. **darker** than
|
|
2231
|
+
hover with 13 points of chroma, while looking fine in the light theme: that is the light-only calibration the
|
|
2232
|
+
criterion exists to reject.
|
|
2233
|
+
|
|
2234
|
+
⚠️ **A pre-existing, unrelated finding measured while doing this.** In the light theme `--d-muted` and
|
|
2235
|
+
`--d-background` both resolve to `#f5f5f5` (`surface--secondary` and `surface--background` point at the same
|
|
2236
|
+
`neutral--125` step), so the row's **hover is invisible in the light theme** — before R19 and independently of
|
|
2237
|
+
it. `TintaSelezionataDiversaDallHover` asserts that equality, so the day the two tokens diverge the story goes
|
|
2238
|
+
red and the reader finds the reason there.
|
|
2239
|
+
|
|
2240
|
+
⚠️ **`:hover` cannot be engaged in the spec runner.** `userEvent.hover` synthesises pointer events; the
|
|
2241
|
+
`:hover` pseudo-class depends on the real mouse position, which a synthetic event does not move — measured:
|
|
2242
|
+
after `userEvent.hover(row)` the row still reads `rgba(0, 0, 0, 0)`. The stories therefore read the hover
|
|
2243
|
+
**colour** from a probe carrying the row's own `hover:` utility with the variant stripped, derived from the
|
|
2244
|
+
row's class list rather than written by hand.
|
|
2245
|
+
|
|
2246
|
+
### Row header, table name, bypass links (Fase 2, fetta E)
|
|
2247
|
+
|
|
2248
|
+
Three things the grid declares about itself, none of which is visible on a still screenshot.
|
|
2249
|
+
|
|
2250
|
+
**One `<th scope="row">` per row.** Declare it on the column that identifies the row
|
|
2251
|
+
(`rowHeader: true`). Without it, a screen reader reading `199.200` on a 14-column table announces the
|
|
2252
|
+
**column** (“Importo”) and not the **row** — it loses the only datum that makes the figure usable.
|
|
2253
|
+
|
|
2254
|
+
- **At most one column may declare it.** A second one throws from the column registry: two
|
|
2255
|
+
`<th scope="row">` in one row make it announce itself with two different names, and nothing else
|
|
2256
|
+
would report it.
|
|
2257
|
+
- **Opt-in, never derived.** “The first column” fails twice: it may be the column that _groups_ (its
|
|
2258
|
+
cell is then muted, so the row header would carry no name) and it may be hidden by
|
|
2259
|
+
`columnVisibility`.
|
|
2260
|
+
- **The cell count per row does not change.** The identity column changes _element_, not position —
|
|
2261
|
+
`<colgroup>`, the leaf `<th>`s and the row keep corresponding.
|
|
2262
|
+
- **The author style of `th` is reset.** `th` renders `text-align: center; font-weight: 700`, so the
|
|
2263
|
+
column would visibly change on the switch from `<td>`. The lane resets the weight with `font-normal`;
|
|
2264
|
+
the alignment is already covered by `gridAlignClass`, the single derivation, which always emits
|
|
2265
|
+
`text-left` or `text-right`.
|
|
2266
|
+
- **Declared limits.** If the row-header column is **hidden** the row has no header (the grid does not
|
|
2267
|
+
elect another one). If that column is also a `groupBy` column the cell is muted, and the grid renders
|
|
2268
|
+
a `<td>`: an empty `<th scope="row">` is a row header with no name.
|
|
2269
|
+
|
|
2270
|
+
**`aria-colcount`** is the number of `<col>` **minus the filler**. The filler
|
|
2271
|
+
(`[data-grid-col-filler]`) exists to absorb the slack of `table-layout: fixed` and has **no cells**, so
|
|
2272
|
+
declaring it would announce a column that no row contains. There is deliberately **no `aria-colindex`**
|
|
2273
|
+
on the cells, unlike the `aria-rowcount`/`aria-rowindex` pair: rows are virtualized, so a row must say
|
|
2274
|
+
where it is; columns never are, so every declared cell is in the DOM and the browser counts its position
|
|
2275
|
+
itself. `aria-rowcount` is unchanged — its two readings are declared in
|
|
2276
|
+
`docs/analysis/datatable-v2/PIANO-COMPLETAMENTO.md` § «`aria-rowcount` — due letture».
|
|
2277
|
+
|
|
2278
|
+
**A name on the `<table>`**, from `i18n.table.label`. Without it, for someone navigating by regions this
|
|
2279
|
+
is “table” and nothing else — and with two tables on the page (the coexistence the migration needs),
|
|
2280
|
+
two indistinguishable entries in the same list.
|
|
2281
|
+
|
|
2282
|
+
**Two bypass links** (WCAG 2.4.1). Between the search box and the first data row the grid puts the
|
|
2283
|
+
density controls, the full-screen toggle and then three header levels in which every leaf carries a sort
|
|
2284
|
+
button, a menu trigger and a focusable resize handle — dozens of tab stops with no way past them. The
|
|
2285
|
+
two links are the way past: the first goes to the rows, the second to the header (the first is needed
|
|
2286
|
+
more often, so it comes first). They live **outside the `<thead>`** — the criterion is “no tab stop
|
|
2287
|
+
added to the header” — and their targets are the `<thead>` and the `<tbody>`, both `tabindex="-1"`:
|
|
2288
|
+
programmatically focusable, never a tab stop. The ids come from a per-instance `useId()`, **sanitised**,
|
|
2289
|
+
because `useId()` produces characters that must be percent-encoded in a URL fragment and an `href="#…"`
|
|
2290
|
+
would not find its target back.
|
|
2291
|
+
|
|
2292
|
+
### Truncation is recoverable where it truncates, and only there (Fase 2, fetta E)
|
|
2293
|
+
|
|
2294
|
+
When the grid renders a cell whose content is **a string**, it wraps it in a detector:
|
|
2295
|
+
|
|
2296
|
+
- if the text **overflows** its column, the cell gets a focusable target (`[data-grid-cell-expand]`),
|
|
2297
|
+
a tooltip, and the full value in an `sr-only` node;
|
|
2298
|
+
- if it **fits**, the cell gets a bare span (`[data-grid-cell-text]`) — **no target, no tooltip, no tab
|
|
2299
|
+
stop added**. This half is what gives the other one a meaning: a tooltip on every text cell costs one
|
|
2300
|
+
tab stop per rendered cell.
|
|
2301
|
+
|
|
2302
|
+
**The rule is about the rendered content, not the column config.** A `cell:` returning a string
|
|
2303
|
+
(`euro(value)`) is covered; a `cell:` returning a `<Badge>` or a progress bar brings its own box model
|
|
2304
|
+
and the grid does not touch it. Wrapping arbitrary nodes would change the layout of markup the grid did
|
|
2305
|
+
not write.
|
|
2306
|
+
|
|
2307
|
+
**A row stays pasteable.** Selecting a row and pasting it must produce **one** spreadsheet row. That is a
|
|
2308
|
+
property of the _rendered box model_, measured, not of the class list:
|
|
2309
|
+
|
|
2310
|
+
- every box the grid adds inside a cell is **inline-level** (`inline-block`, `inline-flex`); a
|
|
2311
|
+
block-level box makes the selection serializer emit a line break, and a 8-field record becomes 8
|
|
2312
|
+
misaligned rows (measured: **7 line breaks**);
|
|
2313
|
+
- the `sr-only` node carries **`select-none`**; without it the full value appears **twice** in the paste
|
|
2314
|
+
(measured), because `sr-only` content is still serialized;
|
|
2315
|
+
- the visible text is `aria-hidden` so the accessible name is the value **once**, not twice.
|
|
2316
|
+
|
|
2317
|
+
⚠️ **There is deliberately no static gate for this.** The rendered `display` of a cell is not derivable
|
|
2318
|
+
from a grep over Tailwind classes, so a script would certify itself. It is measured on the DOM, in
|
|
2319
|
+
`app-data-grid-casi-limite.stories.tsx`.
|
|
2320
|
+
|
|
2321
|
+
⚠️ **Declared limit — the guarantee is per data row, not per selection.** A **group row** (P9) renders
|
|
2322
|
+
its grouping value inside a `flex` box, which is block-level, so selecting a group row emits line breaks
|
|
2323
|
+
(measured in Chromium: **4** for one group row). It was left as it is, because it does not touch the
|
|
2324
|
+
guarantee: selecting one **data** row while grouping is active still yields **0** line breaks (measured),
|
|
2325
|
+
and a selection that spans several rows already emits one break per `<tr>` by definition — 20 data rows
|
|
2326
|
+
plus 2 group rows measured 27 breaks against the ~22 that the row count alone accounts for. So a group
|
|
2327
|
+
row is not pasteable as a single record either way. "Copy a row, get a record" is a promise about a data
|
|
2328
|
+
row.
|
|
2329
|
+
|
|
2330
|
+
⚠️ **`TruncatedCell` is not usable here, and this is measured.** It renders a `<button>` wrapping a
|
|
2331
|
+
`<div class="flex min-w-0 flex-1">` that none of its props can override (`wrapperClassName` → the
|
|
2332
|
+
button, `className` → the inner text div, the flex box between them → nothing). Substituted into **one**
|
|
2333
|
+
cell, the pasted record went from **0 line breaks / 12 tabs to 2 / 11**. It also enables its trigger
|
|
2334
|
+
whenever the cell has any content, i.e. one tab stop per rendered cell — the opposite of this criterion.
|
|
2335
|
+
|
|
2336
|
+
### Row header and row menu (Fase 2, fetta E)
|
|
2337
|
+
|
|
2338
|
+
`createGridRowMenuColumn({ id, header, pinned, width, rowMenu })` puts the `…` menu on a column, and
|
|
2339
|
+
**the grid renders it** — the caller only declares the items:
|
|
2340
|
+
|
|
2341
|
+
```tsx
|
|
2342
|
+
createGridRowMenuColumn<Commessa>({
|
|
2343
|
+
id: "azioni",
|
|
2344
|
+
header: "Azioni",
|
|
2345
|
+
pinned: "right",
|
|
2346
|
+
width: 100,
|
|
2347
|
+
rowMenu: (row) => [
|
|
2348
|
+
{ id: "apri", label: `Apri ${row.codice}`, onSelect: () => open(row.id) },
|
|
2349
|
+
{
|
|
2350
|
+
id: "duplica",
|
|
2351
|
+
label: "Duplica",
|
|
2352
|
+
onSelect: () => dup(row.id),
|
|
2353
|
+
disabled: row.chiusa,
|
|
2354
|
+
},
|
|
2355
|
+
{
|
|
2356
|
+
id: "elimina",
|
|
2357
|
+
label: "Elimina",
|
|
2358
|
+
destructive: true,
|
|
2359
|
+
onSelect: () => del(row.id),
|
|
2360
|
+
},
|
|
2361
|
+
],
|
|
2362
|
+
});
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
The items are **data, not JSX**, and that is what lets the grid guarantee the four properties that go
|
|
2366
|
+
wrong silently:
|
|
2367
|
+
|
|
2368
|
+
1. **`modal={false}`.** A modal dropdown puts `aria-hidden` on the rest of the page and locks body
|
|
2369
|
+
scroll: with both tables mounted, opening a row menu would hide the old `DataTable` from assistive
|
|
2370
|
+
tech. (Measured: with `modal`, the grid itself ends up inside an `aria-hidden` subtree.)
|
|
2371
|
+
2. **`align="end"`.** The menu column is pinned right, so a panel aligned to the trigger's left edge
|
|
2372
|
+
leaves the viewport. Measured with `align="start"`: the panel's right edge sat **109px** away from the
|
|
2373
|
+
trigger's, against a budget of 8px.
|
|
2374
|
+
3. **The z-index at full screen.** `DropdownMenuContent` self-portals to `<body>` at `z-50` while a
|
|
2375
|
+
full-screen grid sits at 110, so without the inline raise from `gridZ.fullscreenOverlay` the menu is
|
|
2376
|
+
painted _under_ the grid.
|
|
2377
|
+
4. **`data-variant="destructive"`.** Only `DropdownMenuItem` honours it — `CheckboxItem`, `RadioItem`,
|
|
2378
|
+
`SubTrigger` and `Label` have no such channel.
|
|
2379
|
+
|
|
2380
|
+
None of the four is verifiable on a `ReactNode` handed over by the caller.
|
|
2381
|
+
|
|
2382
|
+
**The trigger's accessible name is composed with the row's identity** (`Row actions: PAT-01`), taken from
|
|
2383
|
+
the `rowHeader` column, falling back to the row id. With thirty rendered rows, thirty buttons all called
|
|
2384
|
+
“Actions” are thirty identical entries in a screen reader's controls list: it says how many commands
|
|
2385
|
+
exist and not what they act on.
|
|
2386
|
+
|
|
2387
|
+
`rowMenu` is a **function of the row** because the items depend on the row — a closed job cannot be
|
|
2388
|
+
duplicated. Returning an empty list renders no trigger: a button that does nothing is worse than no
|
|
2389
|
+
button.
|
|
2390
|
+
|
|
2391
|
+
### The `table` i18n section (Fase 2, fetta E)
|
|
2392
|
+
|
|
2393
|
+
Optional, like every section added after P1, with the fallback from `defaultGridI18n` (English, as every
|
|
2394
|
+
default in this library):
|
|
2395
|
+
|
|
2396
|
+
| Key | Default | Where it shows |
|
|
2397
|
+
| -------------- | ------------------------------ | -------------------------------------------------------------- |
|
|
2398
|
+
| `label` | `"Data grid"` | `aria-label` on the `<table>` |
|
|
2399
|
+
| `skipToRows` | `"Skip to the data rows"` | The first bypass link |
|
|
2400
|
+
| `skipToHeader` | `"Skip to the column headers"` | The second bypass link |
|
|
2401
|
+
| `expandCell` | `"Full text"` | Composed with the value in the truncated cell's `sr-only` node |
|
|
2402
|
+
| `rowMenu` | `"Row actions"` | Composed with the row's identity on the `…` trigger |
|
|
2403
|
+
|
|
2404
|
+
⚠️ **`i18n` replaces the bag, it does not merge into it.** Passing only `{ table: {...} }` kills the grid
|
|
2405
|
+
on the first render (`i18n.sorting.sort` is undefined) — the non-optional sections have no per-section
|
|
2406
|
+
fallback. Spread the default: `{ ...defaultGridI18n, table: {...} }`.
|
|
2407
|
+
|
|
2408
|
+
### Saved views, and the migration surface (P10)
|
|
2409
|
+
|
|
2410
|
+
`preset` applies a saved view **in one tick**; `onPresetApplied` lets the consumer realign the pieces
|
|
2411
|
+
P7 lifted above the grid.
|
|
2412
|
+
|
|
2413
|
+
```tsx
|
|
2414
|
+
const filters = useGridFilters({ columns: toGridFilterColumns(columns) });
|
|
2415
|
+
const [preset, setPreset] = useState<GridViewPreset>();
|
|
2416
|
+
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
|
2417
|
+
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
|
2418
|
+
|
|
2419
|
+
<AppDataGrid
|
|
2420
|
+
columns={columns}
|
|
2421
|
+
data={rows}
|
|
2422
|
+
preset={preset}
|
|
2423
|
+
// The two capture callbacks: notify-only, restored through `preset.view`.
|
|
2424
|
+
onColumnWidthsChange={(next) => setColumnWidths({ ...next })}
|
|
2425
|
+
onColumnOrderChange={setColumnOrder}
|
|
2426
|
+
// The IN-GRID registry is the only reorder the grid performs — and it renders
|
|
2427
|
+
// only with `toolbar.columns`, no custom `render`, AND a writable visibility.
|
|
2428
|
+
toolbar={{ columns: {} }}
|
|
2429
|
+
filters={filters.badges}
|
|
2430
|
+
columnVisibility={filters.columnVisibility}
|
|
2431
|
+
// ⚠️ REQUIRED HERE, and not only for the checkbox: without it
|
|
2432
|
+
// `canWriteVisibility` is false, the columns trigger is not rendered, and
|
|
2433
|
+
// `onColumnOrderChange` becomes a prop that can never fire.
|
|
2434
|
+
onColumnVisibilityChange={(next) => {
|
|
2435
|
+
for (const [id, visible] of Object.entries(next))
|
|
2436
|
+
if (filters.columnVisibility[id] !== visible)
|
|
2437
|
+
filters.setColumnVisible(id, visible);
|
|
2438
|
+
}}
|
|
2439
|
+
onPresetApplied={(p) => {
|
|
2440
|
+
filters.restore(p.query.filters, p.view.columnVisibility);
|
|
2441
|
+
// ⚠️ RE-SEEDING IS MANDATORY, not courtesy: `view/preset` replaces both
|
|
2442
|
+
// slices wholesale and no capture callback announces it, so without these
|
|
2443
|
+
// two lines the next save would undo the restore in silence.
|
|
2444
|
+
setColumnWidths({ ...p.view.columnWidths });
|
|
2445
|
+
setColumnOrder([...p.view.columnOrder]);
|
|
2446
|
+
}}
|
|
2447
|
+
onQueryChange={fetchPage}
|
|
2448
|
+
/>;
|
|
2449
|
+
```
|
|
2450
|
+
|
|
2451
|
+
**The example used to model a combination that cannot work**, and it is worth naming because it was
|
|
2452
|
+
printed here: it passed `columnVisibility` **without** `onColumnVisibilityChange`, which switches the
|
|
2453
|
+
columns trigger off — so the in-grid registry did not exist, and with it the only reorder the grid
|
|
2454
|
+
performs. Copying that shape and adding `onColumnOrderChange` would give you a prop that never fires.
|
|
2455
|
+
|
|
2456
|
+
**Every field of `GridViewPresetView` now has a capture channel.** `density` through `onDensityChange`
|
|
2457
|
+
(whose only caller is preset application since `adg-c12` — to capture the user's own density you mount
|
|
2458
|
+
`GridDensityControl` yourself and own the state), `columnVisibility` through `onColumnVisibilityChange`,
|
|
2459
|
+
and `columnWidths` / `columnOrder` through the two callbacks above. All four assign into
|
|
2460
|
+
`GridViewPresetView` with no mapping and no cast. The **query** half is not fixed: `onQueryChange` exists
|
|
2461
|
+
only on the server branch (on the client branch it is `never`), and its `GridServerState` shape does not
|
|
2462
|
+
line up with `GridViewPresetQuery` — `computedSorting` carries parallel `sort_by` / `sort_order` arrays
|
|
2463
|
+
where the preset wants `GridSortDescriptor[]`. That mapping is still by hand. Proof of the view
|
|
2464
|
+
round-trip: the stories under «CRITERIO 6» in `CI-CD/AppDataGrid/Preset di vista (P10)`, which count the
|
|
2465
|
+
invocations rather than looking at the table — a callback fired once per `pointermove` of a thirty-step
|
|
2466
|
+
drag and one fired at gesture end render the same table.
|
|
2467
|
+
|
|
2468
|
+
⚠️ **A malformed stored view is rejected at the door, not halfway through a render — and the grid
|
|
2469
|
+
itself now consults the door.** `readGridViewPreset` validates, and the list is exhaustive because a list
|
|
2470
|
+
that is not is worse than no list: `version`; `id` and `name`; `query` and inside it `search` (a string),
|
|
2471
|
+
`pageSize` (an integer ≥ 1), `sorting` (an array whose every element is `{id: string, desc: boolean}`, with
|
|
2472
|
+
no repeated id), `filters` (an array whose every element is a `GridBadgeFilter` field by field — including
|
|
2473
|
+
`operator` against the vocabulary of **its own `filterType`** and not merely against the 20 names,
|
|
2474
|
+
`filterType`/`columnType` against their closed unions, and every numeric `value` FINITE — with no
|
|
2475
|
+
repeated id); `view` and inside it `density` (a key of `gridRowHeight`), `columnWidths` (a
|
|
2476
|
+
record, every value finite), `columnVisibility` (a record, every value a boolean), `columnOrder` (an array
|
|
2477
|
+
of strings, no duplicates). Anything else throws `GridViewPresetVersionError`, whose **`reason`** names the
|
|
2478
|
+
offending field path (`"query.filters[2]"`, `"view.columnWidths.code"`); `reason` is `undefined` only on the
|
|
2479
|
+
version path.
|
|
2480
|
+
|
|
2481
|
+
**The committed version (`f5dc84cc`) validated FOUR things and nothing inside them** — `isRecord(query)`,
|
|
2482
|
+
`Array.isArray` on the two arrays, `isRecord(view)` — which was true at field granularity and false at
|
|
2483
|
+
element granularity. (An uncommitted intermediate round checked eight fields; an earlier draft of this
|
|
2484
|
+
paragraph said «eight» and quoted a sentence the repository never contained.) Measured: `query.sorting: [null]` threw
|
|
2485
|
+
`TypeError … (reading 'id')` in `toGridServerState` **and** inside the headers `useMemo`, i.e. in render;
|
|
2486
|
+
`query.filters: [null]` threw `TypeError … (reading 'kind')` inside the `querySignature` `useMemo`; a
|
|
2487
|
+
`filterType` outside the vocabulary reached the library's only explicit `throw`
|
|
2488
|
+
(`getGridFilterTypeOptions`) straight from `GridFilterChips`, in render. Those are blank screens, not
|
|
2489
|
+
half-applied views.
|
|
2490
|
+
|
|
2491
|
+
⚠️ **`found === expected` does NOT mean the payload is healthy.** On a shape refusal `found` is the version
|
|
2492
|
+
that really was there — `1`, because that is true — so `if (e.found !== e.expected) migrate(); else
|
|
2493
|
+
treatAsHealthy();` takes the wrong branch. `reason` is the only discriminator, and the message says in words
|
|
2494
|
+
which of the two happened.
|
|
2495
|
+
|
|
2496
|
+
⚠️ **The door is stricter than the TYPE**, and a consumer who discovers that from a refusal discovered it too
|
|
2497
|
+
late. `pageSize` is `number` in the type and an integer ≥ 1 here (`0` makes `gridResultScope` report 128
|
|
2498
|
+
pages over 128 rows while TanStack slices zero; `Infinity` raised `RangeError: Invalid array length` and a
|
|
2499
|
+
fat integer killed the process with `heap out of memory`, because the skeleton count IS the live page size).
|
|
2500
|
+
`id` and `name` are `string` in the type and unchecked by any compiler once they come from `JSON.parse` —
|
|
2501
|
+
and this function returns `raw as GridViewPreset`, so tolerating an absent `name` would hand back
|
|
2502
|
+
`name: undefined` typed `string`, while `id` is the store's `Map` key, i.e. a view that saves and never
|
|
2503
|
+
reopens. The elements of `sorting`/`filters` and the values of `columnWidths`/`columnVisibility` were typed
|
|
2504
|
+
and never verified. A badge `value` must be FINITE, not merely a number: measured, `value: NaN` went out as
|
|
2505
|
+
`{"importo":{"operator":"gt","value":null}}` — «greater than null» instead of the saved filter. And an
|
|
2506
|
+
`operator` must belong to **its own column's** vocabulary: `{columnType: "string", operator: "checked"}`
|
|
2507
|
+
passed the 20 names, printed the raw id `"checked"` in the chip, rendered no value editor at all
|
|
2508
|
+
(`getGridFilterValueArity` is `"none"` for it) and shipped a boolean operator on a text column. Every
|
|
2509
|
+
operator the grid itself can capture comes from `getGridFilterTypeOptions`, so the door still accepts
|
|
2510
|
+
everything the grid produces. **Consequence:** a preset built with the typed API but carrying `pageSize: 0`
|
|
2511
|
+
type-checks and does not apply; a preset read out of a JSONB column through `node-postgres` comes back with
|
|
2512
|
+
widths as **strings** (`{code: "260"}`) and loses the whole view — coerce the numbers before the door.
|
|
2513
|
+
|
|
2514
|
+
⚠️ **It refuses, it does not sanitise**, and the cost is real: ONE width written as a string loses the whole
|
|
2515
|
+
view. That is the price of keeping `onPresetApplied` a signal you can trust — «applied» or «refused», never
|
|
2516
|
+
«applied three quarters». Returning a cleaned COPY would change what the function's name means, would leave
|
|
2517
|
+
the consumer's store dirty forever, and a silent drop is indistinguishable, for the consumer, from a healthy
|
|
2518
|
+
payload.
|
|
2519
|
+
|
|
2520
|
+
⚠️ **Six declared limits.** (1) The INSIDE of `customFragment` is not validated: the door requires a record
|
|
2521
|
+
and stops. Measured, `{"colonna_inesistente":{"operator":"DROP","value":{"$ne":null}}}` leaves in
|
|
2522
|
+
`computedFilter` byte for byte — a preset carries an **arbitrary filter expression** to the consumer's
|
|
2523
|
+
backend. Not a crash: a different request that succeeds. Validating a recursive shape whose vocabulary
|
|
2524
|
+
belongs to somebody else's backend is the one place where «a second schema to keep aligned with the first»
|
|
2525
|
+
is still a true argument, so **treat a preset as privileged input** if you accept one from a user. (2) A
|
|
2526
|
+
`columnId`, or a `columnOrder` id, that names no column passes — the door is a pure function that never sees
|
|
2527
|
+
the columns. Compare the ids with your own columns after reading if you care. (3) A `kind: "custom"` badge
|
|
2528
|
+
with no `customLabel` passes, because the field is optional in the type — and `customLabel` is the only
|
|
2529
|
+
source of a custom badge's text, so it restores as a nameless chip. (4) `query.search` has no maximum
|
|
2530
|
+
length. (5) A `columnVisibility` that hides EVERY column passes, for the same reason as (2): the door never
|
|
2531
|
+
sees the columns. `view/preset` writes the map wholesale, bypassing the `changeColumnVisibility` guard — the
|
|
2532
|
+
table itself holds (the `visibleTree` floor renders the whole tree), but `useGridFilters` reads the MAP, so
|
|
2533
|
+
`filters.restore(p.query.filters, p.view.columnVisibility)` leaves `activeColumnIds` empty and every column
|
|
2534
|
+
badge is pruned from the chips, the count and the payload while the table shows unfiltered rows. The missing
|
|
2535
|
+
floor is on the map and belongs where a preset is APPLIED. (6) On the LOWER bound of `pageSize` the
|
|
2536
|
+
round-trip is not guaranteed: the door demands an integer ≥ 1, but the reducer seeds the size from the prop
|
|
2537
|
+
with no clamp and `readPreset` copies it verbatim, so `<AppDataGrid pageSize={0}>` captures a view the door
|
|
2538
|
+
then refuses forever. Validate `pageSize` with the door's own predicate (`Number.isInteger && >= 1`) — **not**
|
|
2539
|
+
against `GRID_PAGE_SIZE_OPTIONS`, which only closes the SELECTOR: a grid mounted at `pageSize: 20` is legal
|
|
2540
|
+
and a consumer that drops 20 as «not in the scale» resets the page size on every reload.
|
|
2541
|
+
|
|
2542
|
+
⚠️ **And the other typed-only doors are NOT covered, which is the claim this paragraph refuses to
|
|
2543
|
+
overstate.** After this change there is no known path **through `preset` or `readGridViewPreset`** for a
|
|
2544
|
+
payload to produce a blank screen instead of the declared error. `filters`, `columnVisibility`,
|
|
2545
|
+
`initialState` and `useGridFilters().restore()` are the same kind of compile-time-only contract the `preset`
|
|
2546
|
+
prop used to be: `filters={JSON.parse(…)}` with `[null]` in it reaches `badge.kind` in the same
|
|
2547
|
+
`querySignature` `useMemo`, with no preset anywhere. If you restore badges from storage, run them through
|
|
2548
|
+
your own check.
|
|
2549
|
+
|
|
2550
|
+
**Autosave is still the wrong default, and the reason is now only in the query half.** The two capture
|
|
2551
|
+
callbacks do **not** fire on preset application, precisely so that a restore cannot trigger a save. But
|
|
2552
|
+
`onQueryChange` _does_ fire on preset application — that is the «restoring emits exactly once» property
|
|
2553
|
+
below — so a consumer that saves on every change would still write back what it just read. Save on an
|
|
2554
|
+
explicit user action.
|
|
2555
|
+
|
|
2556
|
+
**Restoring emits exactly once, not four times.** A preset carries sorting, filter, widths, hidden
|
|
2557
|
+
columns and density; applying them one at a time is the natural way to write it, raises
|
|
2558
|
+
`data-emissions` to 4, and asks the server for the same list four times — three of them for a
|
|
2559
|
+
configuration the user never saw. Watching the table, the two implementations are indistinguishable.
|
|
2560
|
+
|
|
2561
|
+
**And the emitted payload comes from the action, not from the lifted props.** `query/preset` carries
|
|
2562
|
+
the filters and the sorting with it, which is the defect class P7 already paid for once (chips whose
|
|
2563
|
+
filters never reached the payload, because the ref had not seen them yet) excluded here by the shape
|
|
2564
|
+
of the action rather than by attention.
|
|
2565
|
+
|
|
2566
|
+
**What a preset does not save**, each for its own reason: `pageIndex` (returning to page 7 only makes
|
|
2567
|
+
sense if the list is yesterday's, which it rarely is — `pageSize` is configuration and _is_ saved);
|
|
2568
|
+
the grid's internal bookkeeping; `fullscreen` (asked for now, not a preference); and which groups
|
|
2569
|
+
were open (the keys are paths of _values_, which fresh data no longer has).
|
|
2570
|
+
|
|
2571
|
+
**The persistence port is an interface**, and the library ships exactly one implementation —
|
|
2572
|
+
`createInMemoryGridViewStore`. A view that dies on reload and a view that lives on a server and
|
|
2573
|
+
follows the user between two machines are two different products, and choosing between them belongs
|
|
2574
|
+
to the application. Shipping a `localStorage` one would mean this library decides the key, the quota
|
|
2575
|
+
policy, what to do when the browser blocks storage, and what happens when two grids on a page share
|
|
2576
|
+
a key — four product decisions every consumer would inherit without asking.
|
|
2577
|
+
|
|
2578
|
+
**The contract is versioned, and an unknown version is a declared error** — never a half-applied
|
|
2579
|
+
state. Reading the fields you recognise and ignoring the rest leaves a table configured halfway: the
|
|
2580
|
+
sort comes back, the filter does not, and the user is looking at data they believe is filtered.
|
|
2581
|
+
|
|
2582
|
+
#### The migration surface
|
|
2583
|
+
|
|
2584
|
+
P10 also closed the question the plan left open since Fase 0. The codemod's `AUTO` count — files a
|
|
2585
|
+
consumer could migrate by renaming — sat at **38 of 297**, and the report named the blockers: six
|
|
2586
|
+
column builders the lane did not have (`createDateColumn`, `createBooleanColumn`,
|
|
2587
|
+
`createMultiSelectColumn`, …), the server-mode trio (`serverMode`, `serverConfig`, `initialState`),
|
|
2588
|
+
and `createInitialState`.
|
|
2589
|
+
|
|
2590
|
+
They are now on the grid, **name for name and shape for shape**, which is what keeps the codemod a
|
|
2591
|
+
rename instead of a rewrite. Measured after: **145 of 297 files AUTO**, and all six boilerplate
|
|
2592
|
+
patterns of the corpus have AUTO call-sites, up from two.
|
|
2593
|
+
|
|
2594
|
+
`scripts/codemod/apply.mjs` performs the transformation. It refuses to write to any directory that
|
|
2595
|
+
does not carry a `.codemod-throwaway` marker, and there is no flag to force it — a flag to force it
|
|
2596
|
+
is how a guard becomes a note.
|
|
2597
|
+
|
|
2598
|
+
⚠️ **A rename is not always enough, and the report says which call-sites those are.** Two of the six
|
|
2599
|
+
boilerplate files hand-write column objects instead of using the builders (`accessorFn`, a `meta`
|
|
2600
|
+
block, a `cell` taking an untyped `info`). Those literals satisfied TanStack's `ColumnDef` and do not
|
|
2601
|
+
satisfy `GridColumnDef` — deliberately, because a grid column is built with a builder. They are the
|
|
2602
|
+
`MANUALE` share the report already counts, and they need a person.
|