apexgrid-skill 2.3.1 → 2.4.0

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/.cursorrules CHANGED
@@ -4,7 +4,7 @@ You are working with `apex-grid`, a Lit-based web-component data grid by ApexCha
4
4
  Documentation: https://github.com/apexcharts/apexgrid
5
5
  NPM: apex-grid (with a hyphen — no `apexgrid` package exists)
6
6
  GitHub: https://github.com/apexcharts/apexgrid
7
- Confirmed versions: apex-grid@3.0.1, lit@^3.0.0
7
+ Confirmed versions: apex-grid@3.6.0, lit@^3.0.0
8
8
 
9
9
  ## Quickstart — register + size the host
10
10
 
@@ -16,7 +16,8 @@ height or it collapses and renders nothing. `setup()` handles both.
16
16
  ```bash
17
17
  npm install apex-grid lit
18
18
  ```
19
- `igniteui-webcomponents` installs transitively — you do NOT import theme CSS from it.
19
+ That is the whole install: `apex-grid` brings `lit`, `@lit/context` and `@lit-labs/virtualizer`.
20
+ There is no theme CSS to import from anywhere (3.5.0 dropped the old Ignite UI dependencies).
20
21
 
21
22
  ### 2. Register + size — `setup()` does both
22
23
  ```ts
@@ -28,10 +29,16 @@ Manual alternative: `import 'apex-grid/define'` (or `ApexGrid.register()`) plus
28
29
 
29
30
  ### 3. Style via `--ag-*` CSS variables (no theme import)
30
31
  The grid is styled out of the box; retheme by overriding `--ag-*` custom properties on
31
- `apex-grid`. The deprecated `theme` option / `configureTheme()` does NOT affect appearance.
32
+ `apex-grid` or on any ancestor, shadow hosts included (3.6). The deprecated `setup({ theme })`
33
+ option / `configureTheme()` does NOT affect appearance.
32
34
  ```css
33
- apex-grid { height: 480px; /* + any --ag-* overrides */ }
35
+ apex-grid { height: 480px; --ag-brand: #7c3aed; /* + any --ag-* overrides */ }
34
36
  ```
37
+ Dark mode: the `theme="dark"` attribute (`tinted` adds brand chrome; `theme="dark tinted"`).
38
+ Since 3.6 a dark grid reads `--ag-dark-*` colour tokens (`--ag-dark-surface`, `--ag-dark-text`, ...)
39
+ and IGNORES the light ones (`--ag-surface`, ...), even when set on the dark grid itself.
40
+ Brand, sizing and typography tokens are shared. In your own CSS, `var(--ag-brand)` has no
41
+ root default: write `var(--ag-brand, #0d6fd3)`.
35
42
 
36
43
  ### Minimal example
37
44
 
@@ -88,7 +95,7 @@ render(
88
95
  1. **Apex Grid is a Lit web component, not a JS class.** Don't `new ApexGrid(...)`. Render `<apex-grid>` and set `.data` / `.columns` as properties.
89
96
  2. **Two setup steps:** register the element and give the host a bounded height — `setup()` does both. **No theme CSS import is needed** (the grid self-styles via `--ag-*`).
90
97
  3. **`columns` and `data` are properties, not attributes.** Use property binding.
91
- 4. **`column.key` must be a real `keyof T`.** No aliases. Compute derived values in `cellTemplate`.
98
+ 4. **`column.key` must be a real `keyof T`.** No aliases. Compute derived values in `cellTemplate` (its `row` is the row element; the record is `row.data`).
92
99
  5. **Set `type` per column.** Primitive `'string'`/`'number'`/`'boolean'` drive sort/filter/editing (default `'string'`). Presentation types also exist: `'select'`, `'rating'`, `'date'`, `'image'`, `'currency'`, `'avatar'`, `'badge'`, `'progress'`, `'sparkline'`, `'status'`.
93
100
  6. **`cellTemplate` and `headerTemplate` must return Lit `TemplateResult`s** (`html\`...\``). Strings render as escaped text.
94
101
  7. **Sort/filter events fire only for UI-initiated operations.** Programmatic `grid.sort(...)` / `grid.filter(...)` are silent.
@@ -145,8 +152,8 @@ render(html`<apex-grid auto-generate .data=${data}></apex-grid>`, root);
145
152
 
146
153
  | Type | Operands |
147
154
  |---|---|
148
- | `'string'` | `contains`, `doesNotContain`, `startsWith`, `endsWith`, `equals`, `doesNotEqual`, `empty`, `notEmpty` |
149
- | `'number'` | `equals`, `doesNotEqual`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `empty`, `notEmpty` |
155
+ | `'string'` and other non-numeric types | `contains`, `doesNotContain`, `startsWith`, `endsWith`, `equals`, `doesNotEqual`, `empty`, `notEmpty` |
156
+ | `'number'`, `'currency'`, `'rating'`, `'progress'` (3.6) | `equals`, `doesNotEqual`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `empty`, `notEmpty` |
150
157
  | `'boolean'` | `all`, `true`, `false`, `empty`, `notEmpty` (all unary — no `searchTerm`) |
151
158
 
152
159
  ```ts
@@ -172,7 +179,7 @@ grid.filter([
172
179
  | `dataPipelineConfiguration` | `{ sort?, filter?, pagination?, quickFilter? }` | Server-side hooks |
173
180
  | `selection` / `editing` / `pagination` / `tree` / `expansion` | config objects | Opt-in: `{ enabled, ... }` (see SKILL.md §5) |
174
181
  | `quickFilter` / `columnReordering` | `string` / `boolean` | Global search / drag-reorder headers |
175
- | `sortExpressions` / `filterExpressions` | array (get/set) | To clear, use `clearSort()` / `clearFilter()` |
182
+ | `sortExpressions` / `filterExpressions` | array (get/set) | Assigning REPLACES (3.6); `[]` clears. `sort()` / `filter()` add |
176
183
  | `selectedRows` / `expandedRows` | `T[]` (get/set) | Snapshots; set to replace |
177
184
  | `rows` (getter) | `ApexGridRow<T>[]` | Currently rendered only (virtualized) |
178
185
  | `dataView` / `pageItems` (getters) | `readonly T[]` | Full post-filter/sort / current page |
@@ -235,7 +242,7 @@ Built-in pipeline is skipped for whichever operations have a hook. Omit one to k
235
242
 
236
243
  ### 5. `column.key` not a `keyof T`
237
244
  ❌ `{ key: 'fullName' }` when not in `T`
238
- ✅ `cellTemplate: ({ row }) => html\`${row.firstName} ${row.lastName}\``
245
+ ✅ `cellTemplate: ({ row }) => html\`${row.data.firstName} ${row.data.lastName}\`` (`row` is the element)
239
246
 
240
247
  ### 6. String from `cellTemplate`
241
248
  ❌ `cellTemplate: ({ value }) => \`<b>\${value}</b>\``
@@ -278,6 +285,25 @@ Built-in pipeline is skipped for whichever operations have a hook. Omit one to k
278
285
 
279
286
  ---
280
287
 
288
+ ## Changed in 3.6
289
+
290
+ - `sortExpressions` / `filterExpressions` assignment REPLACES the sort / filter (`[]` or `null` clears).
291
+ Assigning one expression at a time keeps only the last: assign the full list, or `grid.sort(expr)` to add.
292
+ - `sorted`, `filtered`, `pageChanged`, `quickFilterChanged` fire after the pipeline run, so `dataView` is fresh
293
+ in the listener, async hooks included.
294
+ - `currency`, `rating`, `progress` columns filter with the number operands; a `contains` on them is dropped.
295
+ - A sort `comparer` receives raw values (original casing, `null` included); empty cells sort last otherwise.
296
+ - `applyEdits([{ row, column, value }])` names a row by record (any page, pinned); `rowIndex` stays page-relative.
297
+ - `grid.ownsEvent(e)`: events bubble, so filter out a nested master-detail grid's events with it.
298
+ - `expansion.exportParts` re-exports parts used in `detailTemplate`; every row part now works with `apex-grid::part()`.
299
+ - `rowMoving` / `rowMoved` add `position` and `toIndex`; a row move on a sorted grid clears the sort.
300
+ - Row edit mode: validators get the staged row as `ctx.data`; checkbox and `ctx.commit` join the row edit.
301
+ - Replacing `grid.data` with new records clears undo history; `grid.data = null` shows an empty grid.
302
+ - CSV export prefixes formula-like text with `'` (`exportToCSV({ escapeFormulas: false })` to opt out);
303
+ booleans export `true` only for `true`; currency / progress export as numbers.
304
+
305
+ ---
306
+
281
307
  ## Dev-server reminder
282
308
 
283
309
  ES module imports require an HTTP server — opening with `file://` fails CORS. Use Vite (`npm create vite@latest`) or any static server.
package/SKILL.md CHANGED
@@ -7,14 +7,15 @@ description: >
7
7
  table with `<apex-grid>`. Covers the register-and-size setup (no theme import —
8
8
  the grid styles itself via `--ag-*` CSS variables), the generic
9
9
  `ColumnConfiguration<T>` shape with its 13 column types, Lit `cellTemplate`
10
- requirements, sorting / filtering / quick-filter, pagination, inline editing,
11
- row selection, master-detail and tree rows, CSV export, batch writes via
12
- `applyEdits`, right-to-left layout, the UI-only event model, and the
10
+ requirements, theming and dark mode (`--ag-*` / `--ag-dark-*` tokens,
11
+ `theme="dark"`), sorting / filtering / quick-filter, pagination, inline
12
+ editing, row selection, master-detail and tree rows, CSV export, batch writes
13
+ via `applyEdits`, right-to-left layout, the UI-only event model, and the
13
14
  `dataPipelineConfiguration` hooks for server-side data.
14
15
  metadata:
15
16
  author: ApexCharts
16
- version: "2.3.1"
17
- library_version: "3.5.0"
17
+ version: "2.4.0"
18
+ library_version: "3.6.0"
18
19
  category: data-visualization
19
20
  tags: [grid, data-grid, table, web-component, lit, apex-grid]
20
21
  docs: https://github.com/apexcharts/apexgrid
@@ -38,7 +39,7 @@ That is the whole install. `apex-grid` brings its own runtime dependencies (`lit
38
39
 
39
40
  > **Changed in 3.5.0:** earlier versions also pulled in `igniteui-webcomponents` and `igniteui-theming` transitively. Both are gone. The grid never registered an Ignite UI element, so nothing you wrote against the grid is affected, but code that relied on `igc-*` elements being installed as a side effect must now depend on that package directly.
40
41
 
41
- > Confirmed working versions: `apex-grid@3.5.0`, `lit@^3.0.0`.
42
+ > Confirmed working versions: `apex-grid@3.6.0`, `lit@^3.0.0`.
42
43
 
43
44
  ### 1.2 Register the element + size the host — `setup()` does both
44
45
 
@@ -62,20 +63,44 @@ apex-grid { height: 480px; } /* any explicit height; % works if the parent is
62
63
 
63
64
  `setup({ hostStyles: false })` registers the element but skips the injected host CSS, leaving sizing entirely to you. Without a bounded height (from `setup()` or your own CSS) the grid collapses and shows no rows.
64
65
 
66
+ Since 3.6.0 the `setup()` sizing is adopted into whichever root each grid connects in, so a grid rendered inside another web component's shadow root gets it too. On 3.5 and earlier it reached the page only, and a grid inside a shadow root needed its own height rule there.
67
+
65
68
  > **Do not set `display` on `apex-grid`** — the component declares `:host { display: grid }` for its internal track layout; an outer `display` rule overrides it and collapses the virtualizer.
66
69
 
67
70
  ### 1.3 Styling — `--ag-*` CSS custom properties (no theme import)
68
71
 
69
- The grid is styled out of the box and customized entirely through `--ag-*` CSS custom properties — there is **no theme file to import and no `configureTheme()` call**. Set the variables on `apex-grid` (or an ancestor):
72
+ The grid is styled out of the box and customized entirely through `--ag-*` CSS custom properties. There is **no theme file to import and no `configureTheme()` call**. Set the variables on `apex-grid` or on any ancestor: `:root`, a dashboard panel, or the shadow host of a web component the grid renders in.
70
73
 
71
74
  ```css
72
75
  apex-grid {
73
76
  height: 480px;
74
- /* override any --ag-* variables to retheme; see the package README for the full list */
77
+ --ag-brand: #7c3aed; /* selection, focus, accents; the brand washes follow it */
78
+ --ag-radius: 12px; /* outer card radius */
79
+ --ag-row-h: 40px; /* row height; see the package README for the full token list */
80
+ }
81
+ ```
82
+
83
+ > **Changed in 3.6.0:** the token defaults moved off the grid host onto `:where(:root)`, adopted into the document (through `adoptedStyleSheets`, so no CSP nonce) when the first grid connects. On 3.5 and earlier a value set on an ancestor never reached the grid, because the host's own default beat it; only values set on the `apex-grid` element took effect. Now both do, through shadow roots however deep.
84
+
85
+ **Dark mode.** Add the `theme="dark"` attribute (an attribute, not a property). `theme="tinted"` mixes the brand into the chrome, and the two combine as `theme="dark tinted"`. A dark grid reads its own colour inputs, the `--ag-dark-*` tokens (`--ag-dark-surface`, `--ag-dark-surface-elevated`, `--ag-dark-grid-bg`, `--ag-dark-text`, `--ag-dark-text-muted`, `--ag-dark-hairline`, `--ag-dark-border`, `--ag-dark-row-hover`, `--ag-dark-shadow-card`, ...). Sizing, typography, motion, the brand and the base state colours are shared by both modes, so one place can theme light and dark grids:
86
+
87
+ ```css
88
+ .dashboard {
89
+ --ag-brand: #7c3aed;
90
+ --ag-surface: #fdfcff; /* light grids */
91
+ --ag-dark-surface: #140f24; /* grids with theme="dark" */
75
92
  }
93
+ apex-grid { --ag-grid-shadow: var(--ag-shadow-card); } /* elevated card edge */
94
+ apex-grid[theme~='dark'] { --ag-grid-shadow: var(--ag-dark-shadow-card); } /* the same, on dark grids */
76
95
  ```
77
96
 
78
- > The deprecated `setup({ theme })` option does **not** change the grid's appearance. It only ever forwarded to Ignite UI's `configureTheme()`, and since 3.5.0 it is inert and warns once. Omit it; use `--ag-*` variables instead.
97
+ > **Changed in 3.6.0:** a light colour token (`--ag-surface`, `--ag-text`, `--ag-border`, ...) no longer applies to a dark grid, whether it is set on an ancestor or **on the dark grid element itself**. A rule such as `apex-grid[theme='dark'] { --ag-surface: #111; }` is now ignored: write `--ag-dark-surface`. A `theme="tinted"` grid ignores `--ag-border` and `--ag-dark-border` (its row separators are the brand alone).
98
+
99
+ **Reading tokens in your own CSS** (page styles, markup in a `cellTemplate`): `var(--ag-surface)`, `var(--ag-text-muted)` and the other inputs resolve to their defaults anywhere on the page. `--ag-brand`, `--ag-brand-strong` and the derived washes (`--ag-brand-soft`, `--ag-brand-softer`, `--ag-brand-line`, `--ag-row-hover`, `--ag-surface-alt`) have no root default, so give them a fallback: `var(--ag-brand, #0d6fd3)`. To manage the defaults yourself, `setup({ tokenStyles: false })` skips the adopted sheet and `import 'apex-grid/tokens.css'` ships the same defaults as a file (also the way to get them into server-rendered HTML). The grid renders the same either way.
100
+
101
+ **CSS parts.** Since 3.6.0 every part a body row renders is reachable with `apex-grid::part(...)`: `cell`, `selection-cell`, `selection-checkbox`, `expansion-cell`, `expansion-toggle`, `expansion-chevron`, `detail-panel`, `row-number-cell`, `row-number`, `reorder-handle-cell`, `reorder-handle`, `reorder-grip`. Before, only `cell` was. Parts inside your own `detailTemplate` content are reached by listing them in `expansion.exportParts` (`references/state-and-features.md`).
102
+
103
+ > The deprecated `setup({ theme })` option does **not** change the grid's appearance. It only ever forwarded to Ignite UI's `configureTheme()`, and since 3.5.0 it is inert and warns once. Omit it; use `--ag-*` variables (and the `theme="dark"` attribute) instead.
79
104
 
80
105
  ### 1.4 Minimal example — a styled, sortable, filterable table
81
106
 
@@ -143,7 +168,7 @@ AI models trained before the package was published may suggest `apexgrid` (no hy
143
168
 
144
169
  3. **`columns` and `data` are properties, not attributes.** Use property binding (`.columns=${...}` in Lit, `[columns]=` in Angular, `:columns.prop=` in Vue, `el.columns = ...` in vanilla JS). A stringified `columns="[...]"` HTML attribute becomes the literal string and the grid renders nothing.
145
170
 
146
- 4. **`column.key` must be a real `keyof T`.** No `field` / `dataIndex` / `accessor` aliases. To display a derived value, compute it in `cellTemplate` (which has `row` access).
171
+ 4. **`column.key` must be a real `keyof T`.** No `field` / `dataIndex` / `accessor` aliases. To display a derived value, compute it in `cellTemplate`, whose `row` is the `<apex-grid-row>` element: the record is `row.data`.
147
172
 
148
173
  5. **Set `type` per column.** The primitive types `'string'`, `'number'`, `'boolean'` drive the default sort comparer, filter operands, and editor. The default is `'string'` — leaving a numeric column unset sorts as strings ("10" before "9"). There are also presentation types — `'select'`, `'rating'`, `'date'`, `'image'`, `'currency'`, `'avatar'`, `'badge'`, `'progress'`, `'sparkline'`, `'status'` — that change rendering while sorting/filtering as their underlying value (§4).
149
174
 
@@ -210,27 +235,30 @@ Applied defaults (`DEFAULT_COLUMN_CONFIG`): `{ type: 'string', resizable: false,
210
235
  | `'currency'` | `Intl.NumberFormat` (`currency`, `locale`). Numeric |
211
236
  | `'avatar'` | first letter in a tinted circle |
212
237
  | `'badge'` | colored pill (`badgeVariant`) |
213
- | `'progress'` | health bar, 0..`max` (default 100) |
238
+ | `'progress'` | health bar, 0..`max` (default 100). Numeric |
214
239
  | `'sparkline'` | inline trend chart of a `number[]` (`showDelta`) |
215
240
  | `'status'` | dot + pill (`statusVariant`, or inferred) |
216
241
 
217
242
  > A `'date'` column **does** exist in 3.x — use `type: 'date'` (it accepts `Date`, ISO strings, or millisecond timestamps), not a string column with a custom comparer.
218
243
 
219
- ### Column type → filter operands (primitive types)
244
+ ### Column type → filter operands
220
245
 
221
246
  | `type` | Operand keys in the filter UI |
222
247
  |---|---|
223
- | `'string'` | `contains`, `doesNotContain`, `startsWith`, `endsWith`, `equals`, `doesNotEqual`, `empty`, `notEmpty` |
224
- | `'number'` | `equals`, `doesNotEqual`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `empty`, `notEmpty` |
225
- | `'boolean'` | `all`, `true`, `false`, `empty`, `notEmpty` (all **unary** — no `searchTerm`) |
248
+ | `'string'`, and every type not listed below (`select`, `date`, `image`, `avatar`, `badge`, `sparkline`, `status`) | `contains`, `doesNotContain`, `startsWith`, `endsWith`, `equals`, `doesNotEqual`, `empty`, `notEmpty` |
249
+ | `'number'`, and the numeric types `'currency'`, `'rating'`, `'progress'` | `equals`, `doesNotEqual`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `empty`, `notEmpty` |
250
+ | `'boolean'` | `all`, `true`, `false`, `empty`, `notEmpty` (all **unary**: no `searchTerm`) |
226
251
 
227
252
  ```ts
228
- grid.filter({ key: 'subscribed', condition: 'true' }); // unary — no searchTerm
253
+ grid.filter({ key: 'subscribed', condition: 'true' }); // unary: no searchTerm
254
+ grid.filter({ key: 'price', condition: 'greaterThan', searchTerm: 100 }); // a 'currency' column
229
255
  ```
230
256
 
257
+ > **Changed in 3.6.0:** `currency`, `rating` and `progress` columns filter as numbers. On 3.5 they took the text operands. A text condition such as `contains` on one of them, from `filter()` or a saved state, is now dropped with a console warning.
258
+
231
259
  ### Cell template
232
260
 
233
- Receives `ApexCellContext<T, K>` (`{ parent, row, column, value, commit? }`). Must return a Lit `TemplateResult`:
261
+ Receives `ApexCellContext<T, K>` (`{ parent, row, column, value, commit? }`, where `row` is the `<apex-grid-row>` element and `row.data` the record). Must return a Lit `TemplateResult`:
234
262
 
235
263
  ```ts
236
264
  import { html } from 'lit';
@@ -238,6 +266,8 @@ import { html } from 'lit';
238
266
  { key: 'name', cellTemplate: ({ value, row }) => html`<strong>${value}</strong>` }
239
267
  ```
240
268
 
269
+ Since 3.6.0 a `cellTemplate` runs again for every visible row after each pipeline run (sort, filter, edit, quick filter, tree toggle), so a template that reads other fields of its row stays current. Keep templates cheap.
270
+
241
271
  ### Auto-generated columns — the 3-line demo
242
272
 
243
273
  ```ts
@@ -275,7 +305,7 @@ grid.sort({ key: 'name', direction: 'ascending' }); // programmatic (silen
275
305
 
276
306
  | Property | Type | Notes |
277
307
  |---|---|---|
278
- | `data` | `T[]` | Setting triggers a pipeline run. |
308
+ | `data` | `T[]` | Setting triggers a pipeline run. `null` / `undefined` (a framework prop still loading) shows an empty grid and reads back as `[]` (3.6). Replacing it with new records clears undo history and drops expansion of rows that left (`references/state-and-features.md`). |
279
309
  | `columns` | `ColumnConfiguration<T>[]` | Each entry merged with column defaults. |
280
310
  | `autoGenerate` (attr `auto-generate`) | `boolean` | Ignored if `columns` is non-empty. |
281
311
  | `sortConfiguration` | `{ multiple, triState }` | Multi-column + tri-state sorting toggles. |
@@ -293,7 +323,7 @@ grid.sort({ key: 'name', direction: 'ascending' }); // programmatic (silen
293
323
  | `pinnedRows` *(getter)* | `{ top: T[]; bottom: T[] }` | Currently pinned rows per band. |
294
324
  | `canUndo` / `canRedo` *(getters)* | `boolean` | Undo/redo availability (needs `editing.history.enabled`). |
295
325
  | `exportFormats` *(getter)* | `readonly { id, label }[]` | Export menu formats (CSV in the community grid). |
296
- | `sortExpressions` / `filterExpressions` | `…[]` (get/set) | Current state. To clear, use `clearSort()` / `clearFilter()`. |
326
+ | `sortExpressions` / `filterExpressions` | `…[]` (get/set) | Current state. Assigning **replaces** the whole sort / filter, and `[]` or `null` clears it (3.6; on 3.5 assignment merged and `[]` did nothing). `sort()` / `filter()` add instead. `clearSort()` / `clearFilter()` also clear. |
297
327
  | `selectedRows` / `expandedRows` | `T[]` (get/set) | Snapshots; set to replace (goes through cancellable events). |
298
328
  | `rows` *(getter)* | `ApexGridRow<T>[]` | **Currently rendered only** (virtualized). |
299
329
  | `dataView` *(getter)* | `readonly T[]` | Full post-filter/sort data. |
@@ -308,10 +338,10 @@ grid.sort({ key: 'name', direction: 'ascending' }); // programmatic (silen
308
338
  | `editing` | `{ enabled, mode?: 'cell' \| 'row', trigger?: 'click' \| 'doubleClick', history?: { enabled, stackSize? } }` — plus per-column `editable: true` |
309
339
  | `selection` | `{ enabled, mode?: 'single' \| 'multiple', showCheckboxColumn? }` |
310
340
  | `pagination` | `{ enabled, mode?: 'local' \| 'remote', page?, pageSize?, pageSizeOptions?, totalItems? }` |
311
- | `tree` | `{ enabled, getDataPath: (row) => string[], groupColumnKey?, defaultExpanded?, childIndent? }` — flat data, hierarchy derived |
312
- | `expansion` | `{ enabled, detailTemplate: (ctx) => TemplateResult, isExpandable?, showToggleColumn? }` — master-detail |
341
+ | `tree` | `{ enabled, getDataPath: (row) => string[], groupColumnKey?, defaultExpanded?, childIndent? }`: flat data, hierarchy derived. Under a filter, the ancestors of a match show as their own rows (3.6) |
342
+ | `expansion` | `{ enabled, detailTemplate: (ctx) => TemplateResult, isExpandable?, showToggleColumn?, exportParts? }`: master-detail. `exportParts` (3.6) re-exports parts used in the detail content |
313
343
  | `rowPinning` | `{ enabled }` — sticky top/bottom bands via `pinRow()` / `unpinRow()` |
314
- | `rowReordering` | `{ enabled, applyToData?, handle? }` — drag/keyboard reorder + `moveRow()`; manual order is mutually exclusive with sort |
344
+ | `rowReordering` | `{ enabled, applyToData?, handle? }`: drag/keyboard reorder + `moveRow()`. A sort clears the manual order, and a move clears the sort (3.6) |
315
345
 
316
346
  ```ts
317
347
  grid.editing = { enabled: true, mode: 'cell', trigger: 'doubleClick', history: { enabled: true } };
@@ -333,7 +363,7 @@ Declarative validators (`column.validators`), column groups (`columnGroups`), un
333
363
  | `updateColumns(col \| col[])` | Merge new fields into existing columns by `key`. Triggers a pipeline run. |
334
364
  | `moveColumn(fromKey, toKey, position?)` | Reorder a column (within its pinning group). |
335
365
  | `pinColumn(key, 'start' \| 'end' \| null)` / `unpinColumn(key)` | Programmatic column pinning → `Promise<boolean>`; emits `columnPinning`/`columnPinned`. |
336
- | `exportToCSV(options?)` / `exportAs(formatId, options?)` | Export the data. CSV is built in. `options.source`: `'view'`(def)/`'page'`/`'selected'`/`'all'`. |
366
+ | `exportToCSV(options?)` / `exportAs(formatId, options?)` | Export the data. CSV is built in. `options.source`: `'view'`(def)/`'page'`/`'selected'`/`'all'`. Formula-like CSV text (leading `=`, `+`, `-`, `@`, tab or CR; a plain signed number such as `-5` excepted) gets a leading apostrophe unless `escapeFormulas: false` (3.6). |
337
367
  | pagination | `gotoPage(n)`, `setPageSize(n)`, `nextPage()`, `previousPage()`, `firstPage()`, `lastPage()` → `Promise<boolean>`; emit `pageChanging`/`pageChanged`. |
338
368
  | `setQuickFilter(value)` | Apply global search (`''` clears) → `Promise<boolean>`; emits `quickFilterChanging`/`quickFilterChanged`. |
339
369
  | selection | `selectRow`, `deselectRow`, `toggleRowSelection`, `selectAllRows`, `clearSelection`, `isRowSelected` |
@@ -342,7 +372,8 @@ Declarative validators (`column.validators`), column groups (`columnGroups`), un
342
372
  | row pinning | `pinRow(row, 'top' \| 'bottom')`, `unpinRow(row)` (needs `rowPinning.enabled`) |
343
373
  | row reorder | `moveRow(from, to, position?)` (needs `rowReordering.enabled`) |
344
374
  | editing | `commitEdit()`, `cancelEdit()` (used with `mode: 'row'`) |
345
- | `applyEdits(edits)` | Write many cells as **one** operation: one undo step, one pipeline run. Returns a tally, not a count. (3.5) |
375
+ | `applyEdits(edits)` | Write many cells as **one** operation: one undo step, one pipeline run. Returns a tally, not a count. (3.5) Each entry names its row by `rowIndex` (current page) or, since 3.6, by record: `{ row, column, value }` reaches any page, pinned rows and collapsed tree children. |
376
+ | `ownsEvent(event)` | `true` when an event came from this grid, `false` when from a grid nested in one of its master-detail panels. (3.6) |
346
377
  | undo/redo | `undo()`, `redo()`, `clearHistory()` (needs `editing.history.enabled`) |
347
378
  | state | `getState(options?)` → `GridState`, `setState(partial, options?)` → `SetStateResult`, `getSchema()` → `GridSchema` |
348
379
  | `ApexGrid.register()` / `ApexGrid.tagName` | Static element registration / `'apex-grid'`. |
@@ -367,7 +398,18 @@ Every event fires only for operations driven through the grid's own UI; programm
367
398
  | `rowPinning` / `rowPinned` | row pin / unpin (also fires for `pinRow()`/`unpinRow()`) |
368
399
  | `rowMoving` / `rowMoved` | row reorder (also fires for `moveRow()`) |
369
400
 
370
- **Exceptions to "UI-only".** The dedicated programmatic methods added for pagination (`gotoPage`, `nextPage`, …), quick-filter (`setQuickFilter`), column pinning (`pinColumn` / `unpinColumn`), row pinning (`pinRow` / `unpinRow`), and row reorder (`moveRow`) **do** emit their `*-ing` / `*-ed` pairs (unlike `sort()` / `filter()`, which stay silent).
401
+ **Exceptions to "UI-only".** The dedicated programmatic methods added for pagination (`gotoPage`, `nextPage`, …), quick-filter (`setQuickFilter`), column pinning (`pinColumn` / `unpinColumn`), row pinning (`pinRow` / `unpinRow`), and row reorder (`moveRow`) **do** emit their `*-ing` / `*-ed` pairs (unlike `sort()` / `filter()`, which stay silent). Since 3.6, `setQuickFilter` also fires `pageChanged` when it moves the grid back to the first page; assigning the `quickFilter` property stays silent.
402
+
403
+ **Timing (3.6).** `sorted`, `filtered`, `pageChanged` and `quickFilterChanged` fire after the pipeline run they caused, so a listener that reads `grid.dataView`, `grid.pageItems` or `grid.totalItems` sees the new rows, even with an async `dataPipelineConfiguration` hook. On 3.5 they could fire before an async hook resolved.
404
+
405
+ **Nested grids (3.6).** Grid events bubble and are composed, so a listener on a grid also hears the events of a grid nested in one of its master-detail panels. Filter them inside the listener:
406
+
407
+ ```ts
408
+ grid.addEventListener('cellValueChanged', (e) => {
409
+ if (!grid.ownsEvent(e)) return; // came from a grid inside a detail panel
410
+ save(e.detail.data);
411
+ });
412
+ ```
371
413
 
372
414
  Single-shot events (no `-ing` pair):
373
415
 
@@ -379,7 +421,7 @@ Single-shot events (no `-ing` pair):
379
421
 
380
422
  Operand reference, multi-column sort, and server-side hooks: `references/sort-and-filter.md` and `references/data-pipeline.md`. Row pinning/reorder, undo/redo, validators, column groups, state/schema, and localization: `references/state-and-features.md`.
381
423
 
382
- ### Keyboard & accessibility (3.4, 3.5)
424
+ ### Keyboard & accessibility (3.4 and later)
383
425
 
384
426
  The grid ships full keyboard operation and screen-reader support out of the box; there's nothing to enable. The body uses a **roving-tabindex** focus model: the active cell is the body's single tab stop, so `Tab` moves into and out of the grid in one step, and real focus follows arrow-key navigation (screen readers track cell-to-cell movement).
385
427
 
@@ -388,12 +430,12 @@ The grid ships full keyboard operation and screen-reader support out of the box;
388
430
  | Arrow keys | Move the active cell one cell up / down / left / right |
389
431
  | Home / End | Move to the **first / last cell of the current row** |
390
432
  | Ctrl/Cmd+Home / Ctrl/Cmd+End | Jump to the grid's first / last cell |
391
- | PageUp / PageDown | Page-scroll by a viewport |
433
+ | PageUp / PageDown | Move the active cell by the rows that fit in the visible body (below the sticky header and pinned rows) |
392
434
  | Enter or F2 | Open the active cell's editor (editing is no longer pointer-only) |
393
435
 
394
- Keyboard-driven commit (Enter) or cancel (Escape) returns focus to the cell, so navigation continues from where you left off.
436
+ Keyboard-driven commit (Enter) or cancel (Escape) returns focus to the cell, so navigation continues from where you left off. Since 3.6.0, after tabbing into the body with no cell active yet, the first arrow, Home or PageUp key activates the first cell instead of moving past it (ArrowDown lands on row 0).
395
437
 
396
- **Editing behavior:** clearing a `number` or `currency` editor now commits `null` (previously it wrote `NaN`). An empty or unparseable numeric input leaves the cell empty rather than poisoning the row.
438
+ **Editing behavior:** clearing a `number` or `currency` editor now commits `null` (previously it wrote `NaN`). An empty or unparseable numeric input leaves the cell empty rather than poisoning the row. Since 3.6.0 an open editor stays on the record it was opened for when the data, sort, filter or page change under it, and the edit is cancelled if that record leaves the view; row mode edits a staged copy (`references/state-and-features.md`).
397
439
 
398
440
  **Announcements:** sorting, filtering, selection, paging, row pinning, row reorder, expansion, and undo / redo each announce through a localized live region, and the host applies a localized `aria-label` ("Data grid") plus `aria-multiselectable` when multi-row selection is on. The strings are locale keys (`announce.*`, `header.*`, `grid.label`, `editor.rating`); override them via `localeText` (see `references/state-and-features.md`).
399
441
 
@@ -433,7 +475,7 @@ Arrow-key navigation, column resize and column reorder all follow inline order r
433
475
 
434
476
  ### 5. `column.key` not a real key of `T`
435
477
  ❌ `{ key: 'fullName' }` when `User` has only `firstName` + `lastName`.
436
- ✅ Compute via template: `cellTemplate: ({ row }) => html\`${row.firstName} ${row.lastName}\``.
478
+ ✅ Compute via template: `cellTemplate: ({ row }) => html\`${row.data.firstName} ${row.data.lastName}\``. The context's `row` is the row element; the record is `row.data`.
437
479
 
438
480
  ### 6. String from `cellTemplate`
439
481
  ❌ `cellTemplate: ({ value }) => \`<strong>\${value}</strong>\`` — renders as escaped text.
@@ -449,12 +491,20 @@ Arrow-key navigation, column resize and column reorder all follow inline order r
449
491
 
450
492
  ### 9. Clearing sort/filter
451
493
  ❌ Assuming you must rebuild state to clear it.
452
- ✅ `grid.clearSort()` / `grid.clearFilter()` (optionally per `key`).
494
+ ✅ `grid.clearSort()` / `grid.clearFilter()` (optionally per `key`), or `grid.sortExpressions = []` / `grid.filterExpressions = []` (3.6).
453
495
 
454
496
  ### 10. Expecting events from programmatic operations
455
497
  ❌ Listening for `sorting` after calling `grid.sort(...)` — never fires.
456
498
  ✅ Wrap the programmatic call yourself, or watch `grid.dataView` for any state change.
457
499
 
500
+ ### 11. Building a multi-column sort one assignment at a time
501
+ ❌ `grid.sortExpressions = [byCity]; grid.sortExpressions = [byAge];` expecting both: since 3.6 each assignment **replaces** the sort (the same for `filterExpressions`), so only `byAge` applies.
502
+ ✅ Assign the full list, `grid.sortExpressions = [byCity, byAge]`, or add with `grid.sort(byAge)` / `grid.filter(expr)`.
503
+
504
+ ### 12. Theming a dark grid with the light tokens
505
+ ❌ `apex-grid[theme='dark'] { --ag-surface: #111827; --ag-text: #f9fafb; }`: since 3.6 a dark grid ignores the light colour tokens, even on its own element.
506
+ ✅ `apex-grid { --ag-dark-surface: #111827; --ag-dark-text: #f9fafb; }` (or on any ancestor). Brand, sizing and typography tokens are shared, so `--ag-brand` still applies to both.
507
+
458
508
  ---
459
509
 
460
510
  ## 7. Reference Routing Table
@@ -464,6 +514,6 @@ Arrow-key navigation, column resize and column reorder all follow inline order r
464
514
  | Column types, templates, auto-generation, per-column styling, header/editor templates | `references/columns-and-templates.md` |
465
515
  | Sort & filter — operands, expressions, multi-column, quick-filter, events | `references/sort-and-filter.md` |
466
516
  | Server-side data hooks (sort/filter/pagination/quickFilter), virtualization, `dataView` | `references/data-pipeline.md` |
467
- | Row pinning / reordering, undo-redo, batch edits (`applyEdits`), validators, column groups, state & schema (`getState`/`setState`/`getSchema`), localization, export source | `references/state-and-features.md` |
517
+ | Row pinning / reordering, undo-redo, batch edits (`applyEdits`), validators, row-mode editing, column groups, state & schema (`getState`/`setState`/`getSchema`), localization, export source and CSV escaping, master-detail parts and nested grids (`exportParts`, `ownsEvent`) | `references/state-and-features.md` |
468
518
  | End-to-end vanilla JS (no Lit `render`) | `references/vanilla-js.md` |
469
519
  | Lit, React, Vue, Angular integration | `references/framework-integration.md` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apexgrid-skill",
3
- "version": "2.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "AI coding skill for the apex-grid web component — structured reference docs that help AI assistants (Claude, Cursor, Copilot, etc.) generate correct apex-grid code.",
5
5
  "keywords": [
6
6
  "apex-grid",
@@ -45,7 +45,7 @@ interface BaseColumnConfiguration<T extends object, K extends keyof T = keyof T>
45
45
 
46
46
  ### Column types (`DataType`)
47
47
 
48
- `'string'` / `'number'` / `'boolean'` are the primitive types driving sort, filter, and the default editor. The rest are presentation renderers over a primitive value (and sort/filter as that value): `'select'` (label from `options`, `<select>` editor), `'rating'` (stars, 0..`max`), `'date'` (locale date via `format`; accepts `Date`/ISO/timestamp; `<input type="date">` editor), `'image'` (`<img>`, `shape`/`alt`), `'currency'` (`Intl.NumberFormat`, `currency`/`locale`), `'avatar'`, `'badge'` (`badgeVariant`), `'progress'` (0..`max`), `'sparkline'` (`number[]`, `showDelta`), `'status'` (`statusVariant`).
48
+ `'string'` / `'number'` / `'boolean'` are the primitive types driving sort, filter, and the default editor. The rest are presentation renderers over a primitive value (and sort/filter as that value): `'select'` (label from `options`, `<select>` editor), `'rating'` (stars, 0..`max`), `'date'` (locale date via `format`; accepts `Date`/ISO/timestamp; `<input type="date">` editor), `'image'` (`<img>`, `shape`/`alt`), `'currency'` (`Intl.NumberFormat`, `currency`/`locale`), `'avatar'`, `'badge'` (`badgeVariant`), `'progress'` (0..`max`), `'sparkline'` (`number[]`, `showDelta`), `'status'` (`statusVariant`). Of these, `'currency'`, `'rating'` and `'progress'` are numeric: since 3.6 they filter with the number operands and export as numbers, like `'number'`.
49
49
 
50
50
  Defaults applied on assignment (`DEFAULT_COLUMN_CONFIG`):
51
51
 
@@ -61,7 +61,7 @@ Defaults applied on assignment (`DEFAULT_COLUMN_CONFIG`):
61
61
  | `'number'` | numeric `<` / `>` | `equals`, `doesNotEqual`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `empty`, `notEmpty` |
62
62
  | `'boolean'` | `false < true` | `all`, `true`, `false`, `empty`, `notEmpty` |
63
63
 
64
- The table above lists the three **primitive** types. The presentation types (`'select'`, `'rating'`, `'date'`, `'image'`, `'currency'`, `'avatar'`, `'badge'`, `'progress'`, `'sparkline'`, `'status'`) sort and filter as their underlying value. For dates, use the real `'date'` type — it accepts `Date`, ISO strings, or millisecond timestamps, renders a locale date (`format`), and sorts chronologically. No timestamp/comparer workaround needed:
64
+ The table above lists the three **primitive** types. The presentation types (`'select'`, `'rating'`, `'date'`, `'image'`, `'currency'`, `'avatar'`, `'badge'`, `'progress'`, `'sparkline'`, `'status'`) sort as their underlying value. For filtering, `'currency'`, `'rating'` and `'progress'` take the `'number'` operands (since 3.6; on 3.5 they took the text ones), and the others take the `'string'` operands. With the built-in comparison, empty cells (`null`, `undefined`, `NaN`, invalid `Date`) sort last in both directions (3.6). For dates, use the real `'date'` type: it accepts `Date`, ISO strings, or millisecond timestamps, renders a locale date (`format`), and sorts chronologically. No timestamp/comparer workaround needed:
65
65
 
66
66
  ```ts
67
67
  { key: 'createdAt', type: 'date', format: 'medium', sort: true, filter: true }
@@ -83,7 +83,7 @@ The table above lists the three **primitive** types. The presentation types (`'s
83
83
  }
84
84
  ```
85
85
 
86
- The `comparer` signature is fully typed against `T[K]`.
86
+ The `comparer` signature is fully typed against `T[K]`. Since 3.6 it receives the **raw** cell values, original casing and `null` / `undefined` included, and owns their order (a descending sort reverses its result). On 3.5 it received strings already lower-cased (unless `caseSensitive`), so a lookup written in the data's casing (`'High'`, `'Low'`) never matched and the sort did nothing. Now the lookup above matches only values stored in lower case: write it in the data's own casing, and decide where empty values go.
87
87
 
88
88
  ## Filter configuration per column
89
89
 
@@ -101,12 +101,15 @@ Receives `ApexCellContext<T, K>`:
101
101
  ```ts
102
102
  interface BaseApexCellContext<T, K> {
103
103
  parent: ApexGridCell<T>; // <apex-grid-cell> element
104
- row: ApexGridRow<T>; // <apex-grid-row> element
104
+ row: ApexGridRow<T>; // <apex-grid-row> element; the record is row.data
105
105
  column: ColumnConfiguration<T, K>;
106
106
  value: T[K]; // T[K] — fully typed
107
+ commit?: (value: T[K]) => Promise<boolean>; // editable columns only: write without opening the editor
107
108
  }
108
109
  ```
109
110
 
111
+ Since 3.6 a `cellTemplate` runs again for every visible row, body and pinned, after each pipeline run (sort, filter, quick filter, edit, tree toggle), so a template that reads other fields of `row.data` stays current after an edit to another cell. Keep templates cheap; plain and typed cells are not re-rendered this way. In row edit mode, `commit(value)` stages the value with the row edit instead of writing straight to the record (3.6), so `cancelEdit()` rolls it back.
112
+
110
113
  ```ts
111
114
  import { html } from 'lit';
112
115
 
@@ -118,11 +121,11 @@ const columns: ColumnConfiguration<User>[] = [
118
121
  { key: 'name',
119
122
  cellTemplate: ({ value }) => html`<strong>${value}</strong>` },
120
123
 
121
- // Conditional render with row context
124
+ // Conditional render with row context (row is the element, row.data the record)
122
125
  { key: 'subscribed', type: 'boolean',
123
126
  cellTemplate: ({ value, row }) => html`
124
127
  <input type="checkbox" .checked=${value} disabled />
125
- <small>${row.name}</small>` },
128
+ <small>${row.data.name}</small>` },
126
129
 
127
130
  // Prefer the built-in type over a template where one exists:
128
131
  // `avatar` and `rating` are column types, no template needed.
@@ -173,7 +176,7 @@ import { required, min, max, pattern, custom } from 'apex-grid';
173
176
  validators: [custom((value, ctx) => (value === ctx.data.id ? 'Cannot equal id' : null))] }
174
177
  ```
175
178
 
176
- Built-in factories: `required(message?)`, `min(limit, message?)`, `max(limit, message?)`, `pattern(regex, message?)`, `custom(fn)`. `min`/`max`/`pattern` pass on empty / non-numeric values — compose with `required`. See `references/state-and-features.md` for the `Validator` / `ValidatorContext` shapes and the `cellValidationFailed` payload.
179
+ Built-in factories: `required(message?)`, `min(limit, message?)`, `max(limit, message?)`, `pattern(regex, message?)`, `custom(fn)`. `min`/`max`/`pattern` pass on empty / non-numeric values, so compose with `required`. In row edit mode `ctx.data` is the staged row (the record with the row's pending values applied) since 3.6, so a cross-field rule such as `ctx.data.end >= ctx.data.start` sees the row being saved. See `references/state-and-features.md` for the `Validator` / `ValidatorContext` shapes and the `cellValidationFailed` payload.
177
180
 
178
181
  **Numeric editing (3.4):** clearing a `number` or `currency` editor now commits `null`, not `NaN`. An empty or unparseable numeric input leaves the cell empty instead of writing `NaN` into the row. Editing also opens from the keyboard now (Enter or F2 on the active cell), not only by pointer; see SKILL.md §5 for the full keyboard model. Separately, a spreadsheet-style error value (an object carrying a `#...` code, such as an enterprise formula engine's `#VALUE!`) renders as its code in typed `number` / `currency` columns rather than coercing to `NaN` and showing an empty cell.
179
182
 
@@ -246,7 +249,8 @@ The grid renders a drag handle on the right edge of the column header. Minimum r
246
249
 
247
250
  | ❌ | ✅ |
248
251
  |---|---|
249
- | `{ key: 'fullName' }` when `fullName` isn't in `T` | Add to `T`, or compute via `cellTemplate: ({ row }) => html\`${row.firstName} ${row.lastName}\`` |
252
+ | `{ key: 'fullName' }` when `fullName` isn't in `T` | Add to `T`, or compute via `cellTemplate: ({ row }) => html\`${row.data.firstName} ${row.data.lastName}\`` |
253
+ | `cellTemplate: ({ row }) => row.name` | `row` is the `<apex-grid-row>` element: read the record as `row.data.name` |
250
254
  | `cellTemplate: ({ value }) => \`<b>\${value}</b>\`` (string) | `cellTemplate: ({ value }) => html\`<b>\${value}</b>\`` |
251
255
  | `{ key: 'age', sort: true }` (default `type: 'string'`) | Set `type: 'number'` |
252
256
  | `{ key: 'created', type: 'string', sort: { comparer } }` to fake dates | `{ key: 'created', type: 'date', format: 'medium' }` — a real type (accepts `Date`/ISO/timestamp) |
@@ -66,7 +66,7 @@ The grid runs the stages in a fixed order: **quick-filter → column filter →
66
66
 
67
67
  ### Hooks fire on every state change
68
68
 
69
- The pipeline runs whenever `data`, `columns`, sort state, or filter state changes — including programmatic `sort()` / `filter()` calls. If your server endpoint is expensive, debounce inside the hook:
69
+ The pipeline runs whenever `data`, `columns`, sort state, or filter state changes, including programmatic `sort()` / `filter()` calls. Since 3.6 only the latest run's result is applied: a slower earlier run that resolves after a newer one is discarded, and no longer holds back the newer run's events. If your server endpoint is expensive, still abort or debounce inside the hook, so the superseded requests are not sent at all:
70
70
 
71
71
  ```ts
72
72
  let pending: AbortController | null = null;
@@ -143,6 +143,10 @@ grid.addEventListener('sorted', updateSummary);
143
143
  grid.addEventListener('filtered', updateSummary);
144
144
  ```
145
145
 
146
+ Since 3.6 this is reliable with async hooks too: `sorted`, `filtered`, `pageChanged` and `quickFilterChanged` fire after the pipeline run they caused, so `dataView` already holds the new rows. On 3.5 they could fire before an async hook resolved and read the previous view. (They remain UI-only, except the page and quick-filter methods listed in SKILL.md §5.)
147
+
148
+ Assigning `grid.data = null` or `undefined` (a framework prop still loading) shows an empty grid and reads back as `[]` since 3.6; on 3.5 it threw and kept the previous rows.
149
+
146
150
  ## Common pitfalls
147
151
 
148
152
  | ❌ | ✅ |
@@ -19,7 +19,7 @@ import { setup } from 'apex-grid';
19
19
  setup(); // registers <apex-grid> + adopts a bounded host height
20
20
  ```
21
21
 
22
- No theme import needed — the grid self-styles. Retheme by overriding `--ag-*` CSS variables on `apex-grid`. If you prefer manual control over sizing, call `setup({ hostStyles: false })` (or use `import 'apex-grid/define'`) and add your own rule:
22
+ No theme import needed: the grid self-styles. Retheme by overriding `--ag-*` CSS variables on `apex-grid` or on any ancestor (an app shell, a component's shadow host), and use the `--ag-dark-*` variables for grids with `theme="dark"` (SKILL.md §1.3). If you prefer manual control over sizing, call `setup({ hostStyles: false })` (or use `import 'apex-grid/define'`) and add your own rule:
23
23
 
24
24
  ```css
25
25
  apex-grid {
@@ -109,7 +109,7 @@ export function UsersGrid({ data }: { data: User[] }) {
109
109
  }
110
110
  ```
111
111
 
112
- If you'd rather size per instance instead of globally, replace the global CSS rule with a `style` prop: `style={{ height: '500px', display: 'block' }}`.
112
+ If you'd rather size per instance instead of globally, replace the global CSS rule with a `style` prop: `style={{ height: '500px' }}`. Never add `display` there: the grid needs its own `display: grid`, collapses without it, and logs a console warning on first render when something overrides it.
113
113
 
114
114
  For full TypeScript / JSX intrinsic-element support:
115
115
 
@@ -35,25 +35,39 @@ grid.sort({
35
35
  });
36
36
  ```
37
37
 
38
- `grid.sort(...)` does **not** fire `sorting` / `sorted` events.
38
+ `grid.sort(...)` does **not** fire `sorting` / `sorted` events. It **adds** to the current sort (a key already sorted is updated in place); to replace the sort, assign `sortExpressions`.
39
39
 
40
- ### Reading the active sort state
40
+ ### How rows compare
41
+
42
+ With the built-in comparison, strings compare with `localeCompare` (case folded unless `caseSensitive: true`) and other values with `<` / `>`. Since 3.6:
43
+
44
+ - **Empty cells** (`null`, `undefined`, `NaN`, an invalid `Date`) sort after every real value in **both** directions. Rows that tie keep their incoming order (a stable sort).
45
+ - **A custom `comparer` receives the raw cell values**, original casing and empty values included, and owns their order; a descending sort reverses its result. On 3.5 it received strings already lower-cased, so a lookup comparer over mixed-case values (`'High'`, `'Low'`) ranked every row the same. Write the lookup in the data's own casing, and decide where `null` goes.
46
+
47
+ ### Reading and assigning the sort state
41
48
 
42
49
  ```ts
43
50
  grid.sortExpressions; // SortExpression<T>[]
51
+
52
+ grid.sortExpressions = [ // 3.6: REPLACES the current sort
53
+ { key: 'subscribed', direction: 'descending' },
54
+ { key: 'age', direction: 'ascending' },
55
+ ];
56
+ grid.sortExpressions = []; // clears it (so does null)
44
57
  ```
45
58
 
46
59
  The array is in the order operations were applied (insertion order on the underlying `Map`).
47
60
 
61
+ > **Changed in 3.6.0:** assigning `sortExpressions` replaces the sort, as `setState` does; on 3.5 it merged into it and `[]` was a no-op. Code that assigned one expression at a time to build a multi-column sort now keeps only the last one: assign the full list, or call `grid.sort()`, which still adds. The list applies at the next update against the columns as they are then, so a framework template may bind `sortExpressions` before `columns`. Entries naming an unknown column, or with an unknown direction, are dropped with a console warning (an omitted direction sorts ascending).
62
+
48
63
  ### Clearing sort
49
64
 
50
65
  ```ts
51
66
  grid.clearSort(); // clear everything
52
67
  grid.clearSort('name'); // clear only the `name` column
68
+ grid.sortExpressions = []; // 3.6: same as clearSort()
53
69
  ```
54
70
 
55
- > **Heads-up:** assigning an empty array (`grid.sortExpressions = []`) is a no-op. Always call `clearSort()` to reset.
56
-
57
71
  ### `sortConfiguration`
58
72
 
59
73
  ```ts
@@ -72,25 +86,29 @@ interface BaseFilterExpression<T, K extends keyof T> {
72
86
  key: K;
73
87
  condition: FilterOperation<T[K]> | OperandKeys<T[K]>; // operand object or string key
74
88
  searchTerm?: T[K]; // optional for unary operands (empty / notEmpty / true / false / all)
75
- criteria?: 'and' | 'or'; // for combining with previous expression on same column
89
+ criteria?: 'and' | 'or'; // default 'and'; combines expressions of the SAME column
76
90
  caseSensitive?: boolean; // overrides column config
77
91
  }
78
92
  ```
79
93
 
94
+ Within one column, a row passes when **all** of its `and` expressions pass **or any one** of its `or` expressions passes. Columns always combine with AND: criteria never reach across columns (3.6; on 3.5 an `or` on one column could cancel the `or` conditions of another).
95
+
80
96
  ### Operand sets
81
97
 
82
98
  ```ts
83
99
  import { StringOperands, NumberOperands, BooleanOperands } from 'apex-grid';
84
100
  ```
85
101
 
86
- | Type | Operand keys |
102
+ | Column `type` | Operand keys |
87
103
  |---|---|
88
- | `'string'` | `contains`, `doesNotContain`, `startsWith`, `endsWith`, `equals`, `doesNotEqual`, `empty`, `notEmpty` |
89
- | `'number'` | `equals`, `doesNotEqual`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `empty`, `notEmpty` |
104
+ | `'string'` and every other type not listed below | `contains`, `doesNotContain`, `startsWith`, `endsWith`, `equals`, `doesNotEqual`, `empty`, `notEmpty` |
105
+ | `'number'`, `'currency'`, `'rating'`, `'progress'` | `equals`, `doesNotEqual`, `greaterThan`, `lessThan`, `greaterThanOrEqual`, `lessThanOrEqual`, `empty`, `notEmpty` |
90
106
  | `'boolean'` | `all`, `true`, `false`, `empty`, `notEmpty` |
91
107
 
92
108
  Unary operands (no `searchTerm` needed): `empty`, `notEmpty`, `all`, `true`, `false`.
93
109
 
110
+ Since 3.6 the numeric presentation types `currency`, `rating` and `progress` take the number operands (on 3.5 they took the text ones). A condition name that does not belong to the column's set, such as `contains` on a `currency` column, is dropped with a console warning, from `filter()` and from a saved state alike. Text conditions read `null` / `undefined` as empty text and match other values (numbers, dates) on their text instead of throwing, and the range conditions (`greaterThan`, `lessThan`, ...) no longer match an empty cell as if it were 0.
111
+
94
112
  ### Programmatic filter
95
113
 
96
114
  ```ts
@@ -120,14 +138,21 @@ grid.filter([
120
138
  ]);
121
139
  ```
122
140
 
123
- `grid.filter(...)` does **not** fire `filtering` / `filtered` events.
141
+ `grid.filter(...)` does **not** fire `filtering` / `filtered` events. Like `grid.sort()`, it **adds** to the current filter; assign `filterExpressions` to replace it.
124
142
 
125
- ### Reading the active filter state
143
+ ### Reading and assigning the filter state
126
144
 
127
145
  ```ts
128
146
  grid.filterExpressions; // FilterExpression<T>[]
147
+
148
+ grid.filterExpressions = [ // 3.6: REPLACES the current filter
149
+ { key: 'age', condition: 'greaterThan', searchTerm: 30 },
150
+ ];
151
+ grid.filterExpressions = []; // clears it (so does null)
129
152
  ```
130
153
 
154
+ Assignment follows the same 3.6 rules as `sortExpressions`: it replaces (3.5 merged, and `[]` was a no-op), applies at the next update, and assigning a list equal to the current one (a wrapper that re-binds it on every render) keeps the scroll position. Any other filter change scrolls the grid back to its first row.
155
+
131
156
  Combined across columns. To get state for a single column, find by `key`:
132
157
 
133
158
  ```ts
@@ -175,6 +200,8 @@ grid.addEventListener('filtered', (e) => {
175
200
  | `filtering` | yes | `{ key, expressions, type }` | no |
176
201
  | `filtered` | no | `{ key, state }` | no |
177
202
 
203
+ Since 3.6, `sorted` and `filtered` fire after the pipeline run they caused, so a listener reading `grid.dataView` or `grid.totalItems` sees the new rows, even when a `dataPipelineConfiguration` hook is async. On 3.5 they fired after the next render, which could come before an async hook resolved.
204
+
178
205
  If you need a side-effect on every change regardless of source, watch `grid.dataView` (after `requestUpdate` / via `updated()` in a Lit parent) or wrap your programmatic calls.
179
206
 
180
207
  ## Combining UI + programmatic operations
@@ -186,18 +213,22 @@ UI operations and programmatic operations write to the **same underlying state**
186
213
  const savedSort = grid.sortExpressions;
187
214
  const savedFilter = grid.filterExpressions;
188
215
 
189
- // ... later, after re-mounting
190
- grid.sort(savedSort);
191
- grid.filter(savedFilter);
216
+ // ... later, after re-mounting: assignment replaces whatever is there (3.6)
217
+ grid.sortExpressions = savedSort;
218
+ grid.filterExpressions = savedFilter;
192
219
  ```
193
220
 
221
+ `grid.sort(savedSort)` / `grid.filter(savedFilter)` would add onto any sort or filter already applied instead. For a full snapshot (columns, paging, selection too), use `getState()` / `setState()` (`references/state-and-features.md`).
222
+
194
223
  ## Common pitfalls
195
224
 
196
225
  | ❌ | ✅ |
197
226
  |---|---|
198
227
  | Listening for events on programmatic calls | Wrap the call yourself, or watch `grid.dataView` |
199
- | `grid.filter({ key: 'age', condition: 'contains' })` (string operand on number column) | Match operand to type — `condition: 'greaterThan'` for number columns |
200
- | `grid.sortExpressions = []` to clear | `grid.clearSort()` |
228
+ | `grid.filter({ key: 'age', condition: 'contains' })` (string operand on number column) | Match operand to type: `condition: 'greaterThan'` for `number`, `currency`, `rating` and `progress` columns (a mismatched name is dropped with a console warning) |
229
+ | `grid.sortExpressions = [a]; grid.sortExpressions = [b];` to build a two-column sort | Each assignment replaces (3.6): assign `[a, b]`, or `grid.sort(b)` to add |
230
+ | Restoring with `grid.sort(saved)` onto a grid that already has a sort | `grid.sortExpressions = saved` replaces it (3.6) |
231
+ | A `comparer` that expects lower-cased strings | Since 3.6 it receives raw values (original casing, `null` included) |
201
232
  | Multi-column sort with `sortConfiguration.multiple = false` | Either set `multiple: true` or call `grid.sort` with a single expression |
202
233
  | Forgetting `searchTerm` on a non-unary operand | Add `searchTerm`, or use a unary operand (`empty` / `notEmpty` / `true` / `false` / `all`) |
203
234
  | Mutating `e.detail` in a `sorted` / `filtered` listener | Only `sorting` / `filtering` are cancellable / mutable; the post-events are read-only by design |
@@ -1,6 +1,6 @@
1
1
  # State, Localization & Interaction Features (3.1+)
2
2
 
3
- > Everything here landed in **apex-grid 3.1 – 3.4** on top of the 3.0 API. The setup rules from `SKILL.md` (register + bounded host height, no theme import) still apply. Row pinning / reordering, undo-redo, and validators are all **opt-in** via a config object — off by default.
3
+ > Everything here landed in **apex-grid 3.1 and later** on top of the 3.0 API; version tags mark what is newer. The setup rules from `SKILL.md` (register + bounded host height, no theme import) still apply. Row pinning / reordering, undo-redo, and validators are all **opt-in** via a config object, off by default.
4
4
 
5
5
  ## Row pinning — sticky top / bottom bands
6
6
 
@@ -21,7 +21,7 @@ grid.pinnedRows; // getter → { top: T[]; bottom
21
21
  | `unpinRow(row)` | `(T) => boolean` | No-op (returns `true`) when not pinned. |
22
22
  | `pinnedRows` *(getter)* | `{ top: T[]; bottom: T[] }` | In pin order. |
23
23
 
24
- Events: `rowPinning` (cancellable) → `rowPinned`, both `CustomEvent<{ row: T; position: 'top' | 'bottom' | null }>` (`null` when unpinning). Pins survive sort / filter / pagination as long as row identity is preserved; re-pin after a wholesale `data` swap.
24
+ Events: `rowPinning` (cancellable) → `rowPinned`, both `CustomEvent<{ row: T; position: 'top' | 'bottom' | null }>` (`null` when unpinning). Pins survive sort / filter / pagination as long as row identity is preserved. Since 3.6, replacing `grid.data` carries the pins onto the new rows: matched by `rowId` when it is set, otherwise a pin whose object left the data is dropped (re-pin those). While `rowPinning.enabled` is `false`, `pinnedRows` reports none and the rows return to the body, but the pins are kept (`getState` still saves them) and come back when pinning is re-enabled.
25
25
 
26
26
  ## Row reordering — drag / keyboard / API
27
27
 
@@ -36,7 +36,26 @@ grid.moveRow(2, 0, 'before'); // (from, to, position?: 'before
36
36
  | `applyToData` | `boolean` | `false` | `true` also splices `grid.data` in place; otherwise the order lives in the grid and you persist via `rowMoved`. |
37
37
  | `handle` | `boolean` | `true` | `true` renders a leading grip cell (drag only from it); `false` drags from anywhere on the row. |
38
38
 
39
- `moveRow(from, to, position?)` indices are into `grid.pageItems`; returns `boolean`. A manual order is **mutually exclusive with column sorting** — applying a sort clears it. Events: `rowMoving` (cancellable) → `rowMoved`, both `CustomEvent<{ from: number; to: number; data: T }>`.
39
+ `moveRow(from, to, position?)` indices are into `grid.pageItems`; returns `boolean`, `false` when disabled, out of range, cancelled, or (3.6) a no-op move that would leave the row where it is, which fires no events. A manual order is **mutually exclusive with column sorting**: applying a sort clears the manual order, and since 3.6 a move on a sorted grid clears the sort and keeps the moved order (on 3.5 an `applyToData` move was undone by the re-sort). Rows hidden by a filter keep their place when a visible row moves, and replacing `grid.data` carries the order onto the new rows (by `rowId` when set).
40
+
41
+ Events: `rowMoving` (cancellable) → `rowMoved`, both `CustomEvent<ApexRowMovingEvent<T>>`:
42
+
43
+ ```ts
44
+ interface ApexRowMovingEvent<T> {
45
+ from: number; // view index before the move
46
+ to: number; // the target row's view index
47
+ position: 'before' | 'after'; // 3.6: which side of the target it lands
48
+ toIndex: number; // 3.6: the view index it lands at (moveRow(0, 3) lands at 2)
49
+ data: T; // the moved record
50
+ }
51
+
52
+ // Mirror a move in your own array ordered like the view (the indices are view indices):
53
+ grid.addEventListener('rowMoved', ({ detail: { from, toIndex } }) => {
54
+ order.splice(toIndex, 0, ...order.splice(from, 1));
55
+ });
56
+ ```
57
+
58
+ Pressing Escape after keyboard grab-and-move steps restores the original order (and `grid.data` with `applyToData`, and any sort) and fires `rowMoved` for the way back, so a persisted order stays in sync.
40
59
 
41
60
  ## Inline-edit undo / redo
42
61
 
@@ -52,6 +71,8 @@ grid.canUndo; grid.canRedo; // getters (false unless history
52
71
 
53
72
  `stackSize` defaults to `100` (older commands evicted past the cap). Event: `historyChanged` — `CustomEvent<{ canUndo: boolean; canRedo: boolean }>`, fires after any record / undo / redo / clear (drive toolbar button enablement from it).
54
73
 
74
+ **Data reloads (3.6).** Replacing `grid.data` with new records clears the history and fires `historyChanged`; before, undo replayed onto the old, detached objects and nothing changed on screen. Data that keeps the edited records, such as `grid.data = [...grid.data, newRow]`, keeps its history, and so do in-place edits. A `cellValueChanged` fired by undo / redo reports `rowIndex: -1` when the record is pinned or on another page, so identify the row by `event.detail.data`, not by index.
75
+
55
76
  ## Batch edits: `applyEdits()` (3.5)
56
77
 
57
78
  Writes many cells as a single operation. Each write still goes through the same choke point interactive editing uses, so it emits the cancellable `cellValueChanging` and then `cellValueChanged`, and runs the column's validators. What differs from looping `editCell` + `commitEdit` is that the whole set lands as **one undo step and one pipeline run**: a thousand round-trips would otherwise mean a thousand undo entries and a thousand re-renders.
@@ -62,9 +83,17 @@ const tally = grid.applyEdits([
62
83
  { rowIndex: 3, column: 'discount', value: 0 },
63
84
  ]);
64
85
  // { applied: 2, unchanged: 0, invalid: 0, cancelled: 0, skipped: 0 }
86
+
87
+ // 3.6: name the row by record to reach every selected row, on any page
88
+ grid.applyEdits(grid.selectedRows.map((row) => ({ row, column: 'discount', value: 0 })));
65
89
  ```
66
90
 
67
- `rowIndex` is **view-relative** and matches `grid.pageItems`, the same as `editCell`. Order is preserved, so a later edit to the same cell wins.
91
+ Each entry names its row one of two ways:
92
+
93
+ - `rowIndex` is **view-relative** and matches `grid.pageItems`, the same as `editCell`, so it reaches the current page only. It must be a whole number from `0` to `pageItems.length - 1`.
94
+ - `row` (3.6) is the record itself, the same object held in `grid.data`. It also reaches rows on other pages, pinned rows and rows under a collapsed tree parent; the events and validators for those see `rowIndex` -1.
95
+
96
+ Order is preserved, so a later edit to the same cell wins.
68
97
 
69
98
  It returns a per-outcome tally rather than a count, because "nothing changed" and "everything was rejected" are different answers:
70
99
 
@@ -74,7 +103,7 @@ It returns a per-outcome tally rather than a count, because "nothing changed" an
74
103
  | `unchanged` | The cell already held that value. |
75
104
  | `invalid` | A `column.validators` rule rejected it. |
76
105
  | `cancelled` | A `cellValueChanging` listener called `preventDefault()`. |
77
- | `skipped` | Unknown column, `rowIndex` out of range, or a column the user could not edit either (editing off, not `editable`, or hidden). |
106
+ | `skipped` | Unknown column, a `rowIndex` that is negative, fractional or past the end of the page (3.5 counted a negative index from the end and rounded a fraction down), or a column the user could not edit either (editing off, not `editable`, or hidden). |
78
107
 
79
108
  Skipping rather than throwing means a partially stale batch still applies what it can. Needs `editing.enabled` and per-column `editable`, the same gate as interactive editing; without `editing.history.enabled` the batch simply is not recorded.
80
109
 
@@ -110,8 +139,15 @@ type Validator<T, K> = (value: unknown, context: ValidatorContext<T, K>) => stri
110
139
  interface ValidatorContext<T, K> { column: ColumnConfiguration<T, K>; data: T; rowIndex: number; }
111
140
  ```
112
141
 
142
+ `rowIndex` is view-relative, or -1 for a pinned row or a record on another page (3.6). In row edit mode `ctx.data` is the **staged row** (3.6): a copy of the record (same prototype) with the row's staged values applied, so a cross-field rule judges the row being saved rather than the stored record. A row commit also re-validates the row's other editable cells against it.
143
+
113
144
  Event: `cellValidationFailed` — `CustomEvent<{ key; rowIndex; data: T; value: unknown; errors: readonly string[] }>`.
114
145
 
146
+ ## Editing behaviour (3.6)
147
+
148
+ - **Row mode edits a staged row.** The cells of the row in edit show their staged values. A boolean checkbox click, or a `cellTemplate` calling `ctx.commit(value)`, joins the row edit instead of writing straight to the record, so `cancelEdit()` rolls it back and `commitEdit()` writes it with the rest. Every cell passes validation and its `cellValueChanging` gate before any is written: one invalid or cancelled cell aborts the whole row.
149
+ - **An open editor stays on its record.** When the data, sort, filter or page changes while a cell is in edit, the editor follows the record it was opened for (on 3.5 it could jump to another row, and Enter wrote into the wrong record). The edit is cancelled when that record leaves the view (removed, filtered out, or replaced by a copy). `editingCell`, `editingRow` and the `rowIndex` of the edit events are -1 while the record is pinned or on another page; identify it by the event's `data`.
150
+
115
151
  ## Column groups — spanning headers
116
152
 
117
153
  A presentational header layer above the column header row. `columns` stays flat; a column joins a group via its `group` id. Members must be **contiguous within one pin region** (the grid warns and skips the spanning cell otherwise). v1 ships static spanning headers only (`collapsible` is reserved / inert).
@@ -163,7 +199,7 @@ interface GetStateOptions<T> { rowId?: (row: T) => string | number; }
163
199
  interface SetStateOptions<T> { rowId?: (row: T) => string | number; strict?: boolean; }
164
200
  ```
165
201
 
166
- Event: `stateChanged` — `CustomEvent<{ state: GridState }>`, debounced after the render pipeline settles. Fires for programmatic (including `setState`) and UI changes. The payload is only computed while a listener is attached (zero-cost otherwise); identical snapshots don't re-fire, so compare before persisting to avoid a save loop.
202
+ Event: `stateChanged`: `CustomEvent<{ state: GridState }>`, debounced after the render pipeline settles. Fires for programmatic (including `setState`) and UI changes. The payload is only computed while a listener is attached (zero-cost otherwise); identical snapshots don't re-fire, so compare before persisting to avoid a save loop. Attach the listener to the grid element itself: the grid counts only listeners registered on it, so a delegated listener on an ancestor receives nothing while none is attached to the grid.
167
203
 
168
204
  ## Programmatic pagination, quick-filter, and column pinning
169
205
 
@@ -181,7 +217,7 @@ await grid.pinColumn('name', 'start'); // 'start' | 'end' | null
181
217
  await grid.unpinColumn('actions'); // = pinColumn(key, null)
182
218
  ```
183
219
 
184
- `gotoPage` / `setPageSize` / `nextPage` / `previousPage` / `firstPage` / `lastPage` emit `pageChanging` → `pageChanged`. `setQuickFilter` emits `quickFilterChanging` → `quickFilterChanged`. `pinColumn` / `unpinColumn` emit `columnPinning` → `columnPinned` and only change the visual render order (the `columns` array isn't reordered).
220
+ `gotoPage` / `setPageSize` / `nextPage` / `previousPage` / `firstPage` / `lastPage` emit `pageChanging` → `pageChanged`. `setQuickFilter` emits `quickFilterChanging` → `quickFilterChanged`, plus (3.6) `pageChanged` when it moves the grid back to the first page. Since 3.6 the `*-ed` events fire after the pipeline run they caused, so awaiting the returned promise, or reading `grid.pageItems` in the listener, sees the new rows even with an async pipeline hook. `pinColumn` / `unpinColumn` emit `columnPinning` → `columnPinned` and only change the visual render order (the `columns` array isn't reordered).
185
221
 
186
222
  ## Localization — `localeText`
187
223
 
@@ -200,6 +236,8 @@ Exports: `EN_LOCALE` (the default dictionary / source of truth for keys), `esLoc
200
236
 
201
237
  **3.4 key changes** (both English and Spanish): the accessibility work added live-region announcement keys (`announce.*`, e.g. `announce.sortedAscending`, `announce.page`, `announce.rowSelected`, `announce.undoOne`), header control / sort-state labels (`header.*`, e.g. `header.sortedAscending`, `header.filterColumn`, `header.columnMenu`), the host label `grid.label` ("Data grid"), and `editor.rating`. The `ai.noAdapter` key was **removed** (drop it from any `localeText` override you kept). `EN_LOCALE` remains the source of truth for the full, current key list.
202
238
 
239
+ **3.6 key additions** (English and Spanish) are used only by enterprise panels: `toolPanel.valueColumn`, `toolPanel.columnLabelColumn`, `chart.mapCategoryNone`, `setFilter.more`, `pivot.columnsTruncated`. A partial `localeText` map needs none of them; a full translation typed as `Record<GridLocaleKey, string>` must add them to compile.
240
+
203
241
  ## Column menu & column separator
204
242
 
205
243
  | Property | Type | Default | Notes |
@@ -220,13 +258,45 @@ grid.columnSeparator = false;
220
258
  |---|---|
221
259
  | `'view'` *(default)* | post-filter, post-sort rows across all pages |
222
260
  | `'page'` | current page slice only |
223
- | `'selected'` | current row selection (insertion order) |
261
+ | `'selected'` | the selected rows in the order the grid shows them, then selected rows the view hides (3.6; on 3.5, selection order) |
224
262
  | `'all'` | the raw `data` array |
225
263
 
226
264
  ```ts
227
265
  grid.exportToCSV(); // download data.csv (view)
228
266
  grid.exportToCSV({ filename: 'users', source: 'selected' });
229
267
  const text = grid.exportToCSV({ filename: '' }); // string only, no download
268
+ grid.exportToCSV({ escapeFormulas: false }); // 3.6: opt out of formula escaping
230
269
  ```
231
270
 
232
- `exportFormats` *(getter)* → `readonly ExportFormat[]` (`{ id, label }`) — the community grid offers CSV only; it drives the toolbar export menu, one item per entry, dispatching to `exportAs(formatId, options?)`. Other `ExportOptions` fields: `columns?` (keys to include), `includeHeader?` (default `true`), `formatter?`; CSV adds `delimiter?` (`','`), `bom?` (`true`), `newline?` (`'\r\n'`).
271
+ `exportFormats` *(getter)* → `readonly ExportFormat[]` (`{ id, label }`). The community grid offers CSV only; it drives the toolbar export menu, one item per entry, dispatching to `exportAs(formatId, options?)`. Other `ExportOptions` fields: `columns?` (narrows to these keys among the visible, exportable columns, kept in display order), `includeHeader?` (default `true`), `formatter?`; CSV (`CSVExportOptions`) adds `delimiter?` (`','`), `bom?` (`true`), `newline?` (`'\r\n'`), `escapeFormulas?` (`true`, 3.6).
272
+
273
+ **Export output changes in 3.6:**
274
+
275
+ - **Formula escaping.** A CSV text cell or header that starts with `=`, `+`, `-`, `@`, a tab or a carriage return is written with a leading apostrophe, so a spreadsheet shows it as text instead of running it. Numbers are untouched, and so is text that is a plain signed decimal (`-5`, `+1.5`). Other text with a leading sign gains the apostrophe too: a phone number such as `+44 20 7946 0958`, or formatter output such as `-$5.00`. Pass `escapeFormulas: false` for the 3.5 output.
276
+ - **Typed values.** `boolean` columns export `true` only for the value `true` (as the checkbox shows), so strings such as `'false'` or `'yes'` and the number `1` export as `false`. `currency` and `progress` columns export as numbers, like `number` and `rating`; a value that is not a number exports as an empty cell.
277
+
278
+ ## Master-detail: styling detail content and nested grids (3.6)
279
+
280
+ Parts used inside your own `detailTemplate` content are not reachable with `apex-grid::part()` until you list them in `expansion.exportParts`. The panel itself is always reachable as `detail-panel`.
281
+
282
+ ```ts
283
+ grid.expansion = {
284
+ enabled: true,
285
+ exportParts: ['order-summary'],
286
+ detailTemplate: ({ data }) => html`<div part="order-summary">${data.total}</div>`,
287
+ };
288
+ ```
289
+
290
+ ```css
291
+ apex-grid::part(detail-panel) { padding: 12px; }
292
+ apex-grid::part(order-summary) { font-weight: 600; }
293
+ ```
294
+
295
+ A grid rendered inside a detail panel is nested in the outer grid's rows, and grid events bubble and are composed, so a listener on the outer grid also receives the inner grid's events (`cellValueChanged`, `rowSelected`, `sorted`, ...). `grid.ownsEvent(event)` returns `true` only for the grid's own events; call it inside the listener, while the event is dispatching. The grid applies the same test to its own interactions, so since 3.6 clicks, Space selection, Ctrl+Z and pointer drags in a nested grid no longer drive the outer one.
296
+
297
+ ```ts
298
+ outer.addEventListener('rowSelected', (e) => {
299
+ if (!outer.ownsEvent(e)) return; // a nested grid's selection
300
+ updateToolbar(outer.selectedRows);
301
+ });
302
+ ```
@@ -134,13 +134,16 @@ grid.clearSort();
134
134
  grid.clearFilter();
135
135
  ```
136
136
 
137
- Programmatic calls don't fire `sorting` / `sorted` / `filtering` / `filtered` events. If you need a hook for every state change regardless of source, watch `grid.dataView` after each programmatic call:
137
+ Programmatic calls don't fire `sorting` / `sorted` / `filtering` / `filtered` events. If you need a hook for every state change regardless of source, read `grid.dataView` once the grid has updated after each programmatic call:
138
138
 
139
139
  ```js
140
140
  grid.sort({ key: 'age', direction: 'descending' });
141
- queueMicrotask(() => console.log('post-sort:', grid.dataView));
141
+ await grid.updateComplete; // the in-memory pipeline has run by now
142
+ console.log('post-sort:', grid.dataView);
142
143
  ```
143
144
 
145
+ A `queueMicrotask` callback is too early: it runs before the pipeline assigns the new view. With an async `dataPipelineConfiguration` hook the new rows exist only once your hook resolves, so act on them there.
146
+
144
147
  ---
145
148
 
146
149
  ## 5. Common vanilla-JS gotchas