@toolbox-web/grid 3.9.1 → 3.9.2

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.
Files changed (77) hide show
  1. package/all.js +1 -1
  2. package/all.js.map +1 -1
  3. package/custom-elements.json +2 -2
  4. package/index.js +1 -1
  5. package/index.js.map +1 -1
  6. package/lib/core/grid.d.ts +8 -4
  7. package/lib/core/plugin/base-plugin.d.ts +7 -0
  8. package/lib/core/types.d.ts +2 -0
  9. package/lib/features/registry.js +1 -1
  10. package/lib/plugins/context-menu/context-menu-plugin.d.ts +9 -4
  11. package/lib/plugins/context-menu/index.js +1 -1
  12. package/lib/plugins/context-menu/index.js.map +1 -1
  13. package/lib/plugins/context-menu/types.d.ts +8 -4
  14. package/lib/plugins/editing/index.js +1 -1
  15. package/lib/plugins/editing/index.js.map +1 -1
  16. package/lib/plugins/editing/internal/dirty-tracking-manager.d.ts +2 -1
  17. package/lib/plugins/filtering/filter-model.d.ts +2 -1
  18. package/lib/plugins/filtering/filtering-plugin.d.ts +5 -2
  19. package/lib/plugins/filtering/index.js +1 -1
  20. package/lib/plugins/filtering/index.js.map +1 -1
  21. package/lib/plugins/grouping-rows/grouping-rows-plugin.d.ts +7 -14
  22. package/lib/plugins/grouping-rows/grouping-rows.d.ts +48 -0
  23. package/lib/plugins/grouping-rows/index.d.ts +1 -1
  24. package/lib/plugins/grouping-rows/index.js +1 -1
  25. package/lib/plugins/grouping-rows/index.js.map +1 -1
  26. package/lib/plugins/grouping-rows/types.d.ts +21 -0
  27. package/lib/plugins/master-detail/index.d.ts +1 -1
  28. package/lib/plugins/master-detail/index.js +1 -1
  29. package/lib/plugins/master-detail/index.js.map +1 -1
  30. package/lib/plugins/master-detail/master-detail-plugin.d.ts +28 -6
  31. package/lib/plugins/master-detail/master-detail.d.ts +0 -8
  32. package/lib/plugins/master-detail/types.d.ts +25 -1
  33. package/lib/plugins/multi-sort/index.js +1 -1
  34. package/lib/plugins/multi-sort/index.js.map +1 -1
  35. package/lib/plugins/selection/index.js +1 -1
  36. package/lib/plugins/selection/index.js.map +1 -1
  37. package/lib/plugins/selection/selection-plugin.d.ts +8 -1
  38. package/lib/plugins/server-side/datasource-types.d.ts +28 -0
  39. package/lib/plugins/server-side/datasource.d.ts +7 -0
  40. package/lib/plugins/server-side/index.js +1 -1
  41. package/lib/plugins/server-side/index.js.map +1 -1
  42. package/lib/plugins/server-side/server-side-plugin.d.ts +61 -8
  43. package/lib/plugins/server-side/types.d.ts +9 -1
  44. package/lib/plugins/tree/index.js +1 -1
  45. package/lib/plugins/tree/index.js.map +1 -1
  46. package/lib/plugins/tree/tree-plugin.d.ts +5 -11
  47. package/lib/plugins/tree/types.d.ts +22 -6
  48. package/lib/plugins/undo-redo/history.d.ts +2 -1
  49. package/lib/plugins/undo-redo/index.js +1 -1
  50. package/lib/plugins/undo-redo/index.js.map +1 -1
  51. package/lib/plugins/undo-redo/types.d.ts +13 -1
  52. package/lib/plugins/undo-redo/undo-redo-plugin.d.ts +15 -2
  53. package/package.json +1 -1
  54. package/umd/grid.all.umd.js +1 -1
  55. package/umd/grid.all.umd.js.map +1 -1
  56. package/umd/grid.umd.js +1 -1
  57. package/umd/grid.umd.js.map +1 -1
  58. package/umd/plugins/context-menu.umd.js +1 -1
  59. package/umd/plugins/context-menu.umd.js.map +1 -1
  60. package/umd/plugins/editing.umd.js +1 -1
  61. package/umd/plugins/editing.umd.js.map +1 -1
  62. package/umd/plugins/filtering.umd.js +1 -1
  63. package/umd/plugins/filtering.umd.js.map +1 -1
  64. package/umd/plugins/grouping-rows.umd.js +1 -1
  65. package/umd/plugins/grouping-rows.umd.js.map +1 -1
  66. package/umd/plugins/master-detail.umd.js +1 -1
  67. package/umd/plugins/master-detail.umd.js.map +1 -1
  68. package/umd/plugins/multi-sort.umd.js +1 -1
  69. package/umd/plugins/multi-sort.umd.js.map +1 -1
  70. package/umd/plugins/selection.umd.js +1 -1
  71. package/umd/plugins/selection.umd.js.map +1 -1
  72. package/umd/plugins/server-side.umd.js +1 -1
  73. package/umd/plugins/server-side.umd.js.map +1 -1
  74. package/umd/plugins/tree.umd.js +1 -1
  75. package/umd/plugins/tree.umd.js.map +1 -1
  76. package/umd/plugins/undo-redo.umd.js +1 -1
  77. package/umd/plugins/undo-redo.umd.js.map +1 -1
@@ -129,14 +129,26 @@ export declare class MasterDetailPlugin extends BaseGridPlugin<MasterDetailConfi
129
129
  private animateCollapse;
130
130
  private expandedRows;
131
131
  private detailElements;
132
- /** Cached measured heights - persists even when elements are virtualized out */
132
+ /**
133
+ * Cached measured heights - persists even when elements are virtualized out.
134
+ * Weak: under `expandAll()` every row scrolled into view is measured, so a
135
+ * strong map would pin rows the ServerSide block cache has already evicted.
136
+ */
133
137
  private measuredDetailHeights;
134
138
  /** Rows that were just expanded by user action and should animate.
135
139
  * Prevents re-animation when rows scroll back into the virtual window. */
136
140
  private rowsToAnimate;
137
- /** Rows currently waiting for datasource:children response. */
138
- private loadingDetails;
139
- /** Child rows received via datasource:children, keyed by parent row reference. */
141
+ /**
142
+ * In-flight `datasource:fetch-children` requests, keyed by row — also the
143
+ * source of truth for {@link isDetailLoading}. Each controller's signal is
144
+ * passed as `FetchChildrenQuery.signal`, so aborting it (collapse, detach)
145
+ * cancels `getChildRows()` and drops its result.
146
+ */
147
+ private detailLoads;
148
+ /**
149
+ * Child rows received via datasource:children, keyed by parent row reference.
150
+ * Weak for the same reason as `measuredDetailHeights`.
151
+ */
140
152
  private detailDataMap;
141
153
  /** Default height for detail rows when not configured */
142
154
  private static readonly DEFAULT_DETAIL_HEIGHT;
@@ -186,6 +198,8 @@ export declare class MasterDetailPlugin extends BaseGridPlugin<MasterDetailConfi
186
198
  adjustVirtualStart(start: number, scrollTop: number, rowHeight: number): number;
187
199
  /**
188
200
  * Expand the detail row at the given index.
201
+ * With `ServerSidePlugin` active, requests the row's detail data via `getChildRows()`
202
+ * (unless already cached or loading), exactly like clicking the expander.
189
203
  * @param rowIndex - Index of the row to expand
190
204
  */
191
205
  expand(rowIndex: number): void;
@@ -196,6 +210,7 @@ export declare class MasterDetailPlugin extends BaseGridPlugin<MasterDetailConfi
196
210
  collapse(rowIndex: number): void;
197
211
  /**
198
212
  * Toggle the detail row at the given index.
213
+ * Expanding requests ServerSide detail data like {@link expand}; collapsing aborts an in-flight request.
199
214
  * @param rowIndex - Index of the row to toggle
200
215
  */
201
216
  toggle(rowIndex: number): void;
@@ -207,16 +222,23 @@ export declare class MasterDetailPlugin extends BaseGridPlugin<MasterDetailConfi
207
222
  isExpanded(rowIndex: number): boolean;
208
223
  /**
209
224
  * Expand all detail rows.
210
- * Skips synthetic rows (e.g., group headers from GroupingRowsPlugin).
225
+ * Skips synthetic rows (group headers from GroupingRowsPlugin, ServerSide loading placeholders).
226
+ * With `ServerSidePlugin` active, requests detail data for every newly expanded
227
+ * loaded row (one `getChildRows()` call per row). Expansion then becomes the
228
+ * default for every detail row until {@link collapseAll} is called, so rows that
229
+ * appear later — ServerSide blocks replacing loading placeholders, or Tree /
230
+ * GroupingRows children — render expanded straight away; their detail data is
231
+ * requested once they scroll into view.
211
232
  */
212
233
  expandAll(): void;
213
234
  /**
214
235
  * Collapse all detail rows.
236
+ * Also ends the {@link expandAll} mode, so rows appearing later stay collapsed.
215
237
  */
216
238
  collapseAll(): void;
217
239
  /**
218
240
  * Get the indices of all expanded rows.
219
- * @returns Array of row indices that are expanded
241
+ * @returns Array of row indices that are expanded, ascending
220
242
  */
221
243
  getExpandedRows(): number[];
222
244
  /**
@@ -3,14 +3,6 @@
3
3
  *
4
4
  * Pure functions for managing detail row expansion state.
5
5
  */
6
- /**
7
- * Toggle the expansion state of a detail row.
8
- *
9
- * **Mutates the input `Set` in place and returns the same reference** (avoids
10
- * O(n) copy per toggle). Do NOT rely on reference inequality (`result !== input`)
11
- * to detect change — inspect the returned set's contents instead.
12
- */
13
- export declare function toggleDetailRow(expandedRows: Set<object>, row: object): Set<object>;
14
6
  /**
15
7
  * Expand a detail row.
16
8
  *
@@ -65,10 +65,34 @@ export interface DetailExpandDetail {
65
65
  /** Whether the row is now expanded */
66
66
  expanded: boolean;
67
67
  }
68
+ /**
69
+ * Event detail for the `detail-load-error` event — the ServerSide data
70
+ * source's `getChildRows()` failed for an expanded detail row.
71
+ *
72
+ * @since 3.10.0
73
+ */
74
+ export interface DetailLoadErrorDetail {
75
+ /** Current index of the row in the processed rows, or `-1` when it is no longer present */
76
+ rowIndex: number;
77
+ /** The row data whose detail failed to load */
78
+ row: Record<string, unknown>;
79
+ /** The error reported by the data source */
80
+ error: Error;
81
+ }
68
82
  declare module '../../core/types' {
69
83
  interface DataGridEventMap {
70
- /** Fired when a detail panel is expanded or collapsed. @group Master-Detail Events */
84
+ /**
85
+ * Fired when a detail panel is expanded or collapsed by click or keyboard.
86
+ * Not fired by the programmatic API (`expand()`, `toggle()`, `expandAll()`, …).
87
+ * @group Master-Detail Events
88
+ */
71
89
  'detail-expand': DetailExpandDetail;
90
+ /**
91
+ * Fired when loading detail data through the ServerSide data source
92
+ * (`getChildRows()`) fails. The row leaves its loading state; collapsing
93
+ * and re-expanding it retries the fetch. @group Master-Detail Events
94
+ */
95
+ 'detail-load-error': DetailLoadErrorDetail;
72
96
  }
73
97
  interface PluginNameMap {
74
98
  masterDetail: import('./master-detail-plugin').MasterDetailPlugin;
@@ -1,2 +1,2 @@
1
- import{BaseGridPlugin as t,DEFAULT_A11Y_MESSAGES as e,resolveCellValue as r}from"@toolbox-web/grid";function i(t,e){if(!t)return;if(!1===("effectiveConfig"in t?t.effectiveConfig:void 0)?.a11y?.announcements)return;const r=t.querySelector?.(".tbw-sr-only");r&&(r.textContent="","function"==typeof requestAnimationFrame?requestAnimationFrame(()=>{r.textContent=e}):r.textContent=e)}function o(t,r,...i){const o=(t&&"effectiveConfig"in t?t.effectiveConfig:void 0)?.a11y?.messages?.[r];return o?o(...i):e[r](...i)}var n=new Intl.Collator(void 0,{sensitivity:"variant"});function s(t,e){return null==t&&null==e?0:null==t?1:null==e?-1:"number"==typeof t&&"number"==typeof e?t-e:t instanceof Date&&e instanceof Date?t.getTime()-e.getTime():"boolean"==typeof t&&"boolean"==typeof e?t===e?0:t?-1:1:n.compare("string"==typeof t?t:String(t),"string"==typeof e?e:String(e))}function l(t,e){const r=t.findIndex(t=>t.field===e);return r>=0?r+1:void 0}function d(t,e){return t.find(t=>t.field===e)?.direction}var a=class extends t{static manifest={queries:[{type:"sort:get-model",description:"Returns the current multi-sort model as SortModel[]"},{type:"sort:set-model",description:"Sets the multi-sort model from context (SortModel[])"}]};name="multiSort";styles='@layer tbw-plugins{.header-cell[data-sort=asc]:after{content:"↑";margin-left:var(--tbw-spacing-xs,.25em);opacity:.8}.header-cell[data-sort=desc]:after{content:"↓";margin-left:var(--tbw-spacing-xs,.25em);opacity:.8}.sort-indicator{margin-left:var(--tbw-spacing-xs,.25em);opacity:.8}.sort-index{font-size:var(--tbw-font-size-2xs,.7em);background:var(--tbw-multi-sort-badge-bg,var(--tbw-color-panel-bg));color:var(--tbw-multi-sort-badge-color,var(--tbw-color-fg));width:var(--tbw-multi-sort-badge-size,1em);height:var(--tbw-multi-sort-badge-size,1em);margin-left:var(--tbw-spacing-xs,.125em);border-radius:50%;justify-content:center;align-items:center;font-weight:600;display:inline-flex}}';get defaultConfig(){return{maxSortColumns:3,showSortIndex:!0}}sortModel=[];cachedSortResult=null;#t=!1;#e=[];get#r(){return this.grid}clearCoreSortState(){this.#r._sortState=null}#i(t){const e=this.grid?.query?.("grouping:get-grouped-fields",null);if(!Array.isArray(e)||0===e.length)return t;const r=e[0];if(!Array.isArray(r)||0===r.length)return t;const i=new Set(r),o=t.filter(t=>!i.has(t.field));return o.length===t.length?t:o}detach(){this.sortModel=[],this.cachedSortResult=null}handleQuery(t){switch(t.type){case"sort:get-model":return[...this.sortModel];case"sort:set-model":{const e=t.context;return!!Array.isArray(e)&&(this.sortModel=[...e],this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[...this.sortModel]}),this.requestRender(),!0)}default:return}}processRows(t){if(0===this.sortModel.length)return this.cachedSortResult=null,[...t];const e=this.#r;if(!e._isGridEditMode&&"number"==typeof e._activeEditRows&&-1!==e._activeEditRows&&this.cachedSortResult&&this.cachedSortResult.length===t.length)return[...this.cachedSortResult];const i=this.#i(this.sortModel),o=t;return i.length>0&&function(t,e,i){if(!e.length)return;const o=e.map(t=>{const e=i.find(e=>e.field===t.field),o=e?.sortComparator??s,n=t.field,l=e?.valueAccessor?t=>r(t,e):t=>t[n];return{field:n,asc:"asc"===t.direction,comparator:o,getValue:l,pinPlaceholders:!e?.sortComparator}}),n=o.some(t=>t.pinPlaceholders)&&function(t){for(let e=0;e<t.length;e++)if(!0===t[e]?.__loading)return!0;return!1}(t),l=t.length,d=o.length,a=new Array(l*d);for(let r=0;r<l;r++){const e=t[r],i=r*d;for(let t=0;t<d;t++)a[i+t]=o[t].getValue(e)}const c=l<=4294967295?new Uint32Array(l):new Array(l);for(let r=0;r<l;r++)c[r]=r;const h=(e,r)=>{if(n){const i=function(t,e){const r=!0===t?.__loading;return r===(!0===e?.__loading)?0:r?1:-1}(t[e],t[r]);if(0!==i)return i}const i=e*d,s=r*d;for(let n=0;n<d;n++){const l=o[n],d=l.comparator(a[i+n],a[s+n],t[e],t[r]);if(0!==d)return l.asc?d:-d}return 0};Uint32Array,c.sort(h);const f=t.slice();for(let r=0;r<l;r++)t[r]=f[c[r]]}(o,i,this.columns),this.cachedSortResult=o,o}onHeaderClick(t){if(!this.columns.find(e=>e.field===t.field)?.sortable)return!1;const e=t.originalEvent.shiftKey,r=this.config.maxSortColumns??3;if(this.sortModel=function(t,e,r,i){const o=t.find(t=>t.field===e);return r?o?"asc"===o.direction?t.map(t=>t.field===e?{...t,direction:"desc"}:t):t.filter(t=>t.field!==e):t.length<i?[...t,{field:e,direction:"asc"}]:t:"asc"===o?.direction?[{field:e,direction:"desc"}]:"desc"===o?.direction?[]:[{field:e,direction:"asc"}]}(this.sortModel,t.field,e,r),this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[...this.sortModel]}),this.requestRender(),this.grid?.requestStateChange?.(),this.sortModel.length>0){const t=this.sortModel.map(t=>`${this.columns.find(e=>e.field===t.field)?.header??t.field} ${"asc"===t.direction?"ascending":"descending"}`);i(this.gridElement,o(this.gridElement,"sortApplied",t.join(", then "),""))}else i(this.gridElement,o(this.gridElement,"sortCleared"));return!0}afterRender(){const t=this.gridElement;if(!t)return;const e=!1!==this.config.showSortIndex;t.querySelectorAll(".header-row .cell[data-field]").forEach(t=>{const r=t.getAttribute("data-field");if(!r)return;const i=l(this.sortModel,r),o=d(this.sortModel,r);if(t.querySelector(".sort-index")?.remove(),o){const r=this.updateSortIndicator(t,o);if(e&&this.sortModel.length>1&&void 0!==i){const e=document.createElement("span");e.className="sort-index",e.textContent=String(i),r.nextSibling?t.insertBefore(e,r.nextSibling):t.appendChild(e)}}else t.classList.contains("sortable")&&this.updateSortIndicator(t,null)})}getSortModel(){return[...this.sortModel]}setSortModel(t){if(this.sortModel=[...t],this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[...t]}),this.requestRender(),this.grid?.requestStateChange?.(),t.length>0){const e=t.map(t=>`${this.columns.find(e=>e.field===t.field)?.header??t.field} ${"asc"===t.direction?"ascending":"descending"}`);i(this.gridElement,o(this.gridElement,"sortApplied",e.join(", then "),""))}}clearSort(){this.sortModel=[],this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[]}),this.requestRender(),this.grid?.requestStateChange?.(),i(this.gridElement,o(this.gridElement,"sortCleared"))}getSortIndex(t){return l(this.sortModel,t)}getSortDirection(t){return d(this.sortModel,t)}getColumnState(t){const e=this.sortModel.findIndex(e=>e.field===t);if(-1!==e)return{sort:{direction:this.sortModel[e].direction,priority:e}}}applyColumnState(t,e){if(this.#t||(this.#e=[...this.sortModel]),e.sort){const r=this.sortModel.findIndex(e=>e.field===t),i={field:t,direction:e.sort.direction};-1!==r?this.sortModel[r]=i:this.sortModel.splice(e.sort.priority,0,i)}else this.sortModel=this.sortModel.filter(e=>e.field!==t);this.clearCoreSortState(),this.#t||(this.#t=!0,queueMicrotask(()=>{this.#t=!1;const t=this.#e;this.#e=[],function(t,e){if(t.length!==e.length)return!1;for(let r=0;r<t.length;r++)if(t[r].field!==e[r].field||t[r].direction!==e[r].direction)return!1;return!0}(t,this.sortModel)||(this.broadcast("sort-change",{sortModel:[...this.sortModel]}),this.requestRender())}))}};export{a as MultiSortPlugin};
1
+ import{BaseGridPlugin as t,DEFAULT_A11Y_MESSAGES as e,resolveCellValue as r}from"@toolbox-web/grid";function i(t,e){if(!t)return;if(!1===("effectiveConfig"in t?t.effectiveConfig:void 0)?.a11y?.announcements)return;const r=t.querySelector?.(".tbw-sr-only");r&&(r.textContent="","function"==typeof requestAnimationFrame?requestAnimationFrame(()=>{r.textContent=e}):r.textContent=e)}function o(t,r,...i){const o=(t&&"effectiveConfig"in t?t.effectiveConfig:void 0)?.a11y?.messages?.[r];return o?o(...i):e[r](...i)}var n=new Intl.Collator(void 0,{sensitivity:"variant"});function s(t,e){return null==t&&null==e?0:null==t?1:null==e?-1:"number"==typeof t&&"number"==typeof e?t-e:t instanceof Date&&e instanceof Date?t.getTime()-e.getTime():"boolean"==typeof t&&"boolean"==typeof e?t===e?0:t?-1:1:n.compare("string"==typeof t?t:String(t),"string"==typeof e?e:String(e))}function l(t,e){const r=t.findIndex(t=>t.field===e);return r>=0?r+1:void 0}function d(t,e){return t.find(t=>t.field===e)?.direction}var a=class extends t{static manifest={queries:[{type:"sort:get-model",description:"Returns the current multi-sort model as SortModel[]"},{type:"sort:set-model",description:"Sets the multi-sort model from context (SortModel[])"}]};name="multiSort";styles='@layer tbw-plugins{.header-cell[data-sort=asc]:after{content:"↑";margin-left:var(--tbw-spacing-xs,.25em);opacity:.8}.header-cell[data-sort=desc]:after{content:"↓";margin-left:var(--tbw-spacing-xs,.25em);opacity:.8}.sort-indicator{margin-left:var(--tbw-spacing-xs,.25em);opacity:.8}.sort-index{font-size:var(--tbw-font-size-2xs,.7em);background:var(--tbw-multi-sort-badge-bg,var(--tbw-color-panel-bg));color:var(--tbw-multi-sort-badge-color,var(--tbw-color-fg));width:var(--tbw-multi-sort-badge-size,1em);height:var(--tbw-multi-sort-badge-size,1em);margin-left:var(--tbw-spacing-xs,.125em);border-radius:50%;justify-content:center;align-items:center;font-weight:600;display:inline-flex}}';get defaultConfig(){return{maxSortColumns:3,showSortIndex:!0}}sortModel=[];cachedSortResult=null;#t=!1;#e=[];get#r(){return this.grid}clearCoreSortState(){this.#r._sortState=null}#i(t){const e=this.grid?.query?.("grouping:get-grouped-fields",null);if(!Array.isArray(e)||0===e.length)return t;const r=e[0];if(!Array.isArray(r)||0===r.length)return t;const i=new Set(r),o=t.filter(t=>!i.has(t.field));return o.length===t.length?t:o}detach(){this.sortModel=[],this.cachedSortResult=null}handleQuery(t){switch(t.type){case"sort:get-model":return[...this.sortModel];case"sort:set-model":{const e=t.context;return!!Array.isArray(e)&&(this.sortModel=[...e],this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[...this.sortModel]}),this.requestRender(),!0)}default:return}}processRows(t){if(0===this.sortModel.length)return this.cachedSortResult=null,t;if("server"===this.grid?.query?.("datasource:sort-mode",null)?.[0])return this.cachedSortResult=null,t;const e=this.#r;if(!e._isGridEditMode&&"number"==typeof e._activeEditRows&&-1!==e._activeEditRows&&this.cachedSortResult&&this.cachedSortResult.length===t.length)return[...this.cachedSortResult];const i=this.#i(this.sortModel),o=[...t];return i.length>0&&function(t,e,i){if(!e.length)return;const o=e.map(t=>{const e=i.find(e=>e.field===t.field),o=e?.sortComparator??s,n=t.field,l=e?.valueAccessor?t=>r(t,e):t=>t[n];return{field:n,asc:"asc"===t.direction,comparator:o,getValue:l,pinPlaceholders:!e?.sortComparator}}),n=o.some(t=>t.pinPlaceholders)&&function(t){for(let e=0;e<t.length;e++)if(!0===t[e]?.__loading)return!0;return!1}(t),l=t.length,d=o.length,a=new Array(l*d);for(let r=0;r<l;r++){const e=t[r],i=r*d;for(let t=0;t<d;t++)a[i+t]=o[t].getValue(e)}const c=l<=4294967295?new Uint32Array(l):new Array(l);for(let r=0;r<l;r++)c[r]=r;const h=(e,r)=>{if(n){const i=function(t,e){const r=!0===t?.__loading;return r===(!0===e?.__loading)?0:r?1:-1}(t[e],t[r]);if(0!==i)return i}const i=e*d,s=r*d;for(let n=0;n<d;n++){const l=o[n],d=l.comparator(a[i+n],a[s+n],t[e],t[r]);if(0!==d)return l.asc?d:-d}return 0};Uint32Array,c.sort(h);const f=t.slice();for(let r=0;r<l;r++)t[r]=f[c[r]]}(o,i,this.columns),this.cachedSortResult=o,o}onHeaderClick(t){if(!this.columns.find(e=>e.field===t.field)?.sortable)return!1;const e=t.originalEvent.shiftKey,r=this.config.maxSortColumns??3;if(this.sortModel=function(t,e,r,i){const o=t.find(t=>t.field===e);return r?o?"asc"===o.direction?t.map(t=>t.field===e?{...t,direction:"desc"}:t):t.filter(t=>t.field!==e):t.length<i?[...t,{field:e,direction:"asc"}]:t:"asc"===o?.direction?[{field:e,direction:"desc"}]:"desc"===o?.direction?[]:[{field:e,direction:"asc"}]}(this.sortModel,t.field,e,r),this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[...this.sortModel]}),this.requestRender(),this.grid?.requestStateChange?.(),this.sortModel.length>0){const t=this.sortModel.map(t=>`${this.columns.find(e=>e.field===t.field)?.header??t.field} ${"asc"===t.direction?"ascending":"descending"}`);i(this.gridElement,o(this.gridElement,"sortApplied",t.join(", then "),""))}else i(this.gridElement,o(this.gridElement,"sortCleared"));return!0}afterRender(){const t=this.gridElement;if(!t)return;const e=!1!==this.config.showSortIndex;t.querySelectorAll(".header-row .cell[data-field]").forEach(t=>{const r=t.getAttribute("data-field");if(!r)return;const i=l(this.sortModel,r),o=d(this.sortModel,r);if(t.querySelector(".sort-index")?.remove(),o){const r=this.updateSortIndicator(t,o);if(e&&this.sortModel.length>1&&void 0!==i){const e=document.createElement("span");e.className="sort-index",e.textContent=String(i),r.nextSibling?t.insertBefore(e,r.nextSibling):t.appendChild(e)}}else t.classList.contains("sortable")&&this.updateSortIndicator(t,null)})}getSortModel(){return[...this.sortModel]}setSortModel(t){if(this.sortModel=[...t],this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[...t]}),this.requestRender(),this.grid?.requestStateChange?.(),t.length>0){const e=t.map(t=>`${this.columns.find(e=>e.field===t.field)?.header??t.field} ${"asc"===t.direction?"ascending":"descending"}`);i(this.gridElement,o(this.gridElement,"sortApplied",e.join(", then "),""))}}clearSort(){this.sortModel=[],this.clearCoreSortState(),this.broadcast("sort-change",{sortModel:[]}),this.requestRender(),this.grid?.requestStateChange?.(),i(this.gridElement,o(this.gridElement,"sortCleared"))}getSortIndex(t){return l(this.sortModel,t)}getSortDirection(t){return d(this.sortModel,t)}getColumnState(t){const e=this.sortModel.findIndex(e=>e.field===t);if(-1!==e)return{sort:{direction:this.sortModel[e].direction,priority:e}}}applyColumnState(t,e){if(this.#t||(this.#e=[...this.sortModel]),e.sort){const r=this.sortModel.findIndex(e=>e.field===t),i={field:t,direction:e.sort.direction};-1!==r?this.sortModel[r]=i:this.sortModel.splice(e.sort.priority,0,i)}else this.sortModel=this.sortModel.filter(e=>e.field!==t);this.clearCoreSortState(),this.#t||(this.#t=!0,queueMicrotask(()=>{this.#t=!1;const t=this.#e;this.#e=[],function(t,e){if(t.length!==e.length)return!1;for(let r=0;r<t.length;r++)if(t[r].field!==e[r].field||t[r].direction!==e[r].direction)return!1;return!0}(t,this.sortModel)||(this.broadcast("sort-change",{sortModel:[...this.sortModel]}),this.requestRender())}))}};export{a as MultiSortPlugin};
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../../../../../../libs/grid/src/lib/core/internal/aria.ts","../../../../../../libs/grid/src/lib/plugins/multi-sort/multi-sort.ts","../../../../../../libs/grid/src/lib/plugins/multi-sort/multi-sort.css?inline","../../../../../../libs/grid/src/lib/plugins/multi-sort/multi-sort-plugin.ts"],"sourcesContent":["/**\n * ARIA Accessibility Helpers\n *\n * Pure functions for managing ARIA attributes on grid elements.\n * Implements caching to avoid redundant DOM writes during scroll.\n *\n * @module internal/aria\n */\n\nimport { DEFAULT_A11Y_MESSAGES } from '../defaults';\nimport type { A11yMessages, GridConfig } from '../types';\n\n// #region Types\n\n/**\n * State for caching ARIA attributes to avoid redundant DOM writes.\n */\nexport interface AriaState {\n /** Last set row count */\n rowCount: number;\n /** Last set column count */\n colCount: number;\n /** Last set aria-label */\n ariaLabel: string | undefined;\n /** Last set aria-labelledby */\n ariaLabelledBy: string | undefined;\n /** Last set aria-describedby */\n ariaDescribedBy: string | undefined;\n /** Last set aria-roledescription */\n ariaRoleDescription: string | undefined;\n /** Last source row count announced via `dataLoaded`; used to suppress duplicate announcements */\n lastAnnouncedSourceCount: number;\n}\n\n/**\n * Create initial ARIA state.\n */\nexport function createAriaState(): AriaState {\n return {\n rowCount: -1,\n colCount: -1,\n ariaLabel: undefined,\n ariaLabelledBy: undefined,\n ariaDescribedBy: undefined,\n ariaRoleDescription: undefined,\n lastAnnouncedSourceCount: -1,\n };\n}\n\n// #endregion\n\n// #region Count Updates\n\n/**\n * Update ARIA row and column counts on grid elements.\n * Uses caching to avoid redundant DOM writes on every scroll frame.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-rowcount/aria-colcount on\n * @param bodyEl - Element to set role=\"rowgroup\" on\n * @param rowCount - Current row count\n * @param colCount - Current column count\n * @returns true if anything was updated\n */\nexport function updateAriaCounts(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n bodyEl: HTMLElement | null,\n rowCount: number,\n colCount: number,\n): boolean {\n // Skip if nothing changed (hot path optimization for scroll)\n if (rowCount === state.rowCount && colCount === state.colCount) {\n return false;\n }\n\n const prevRowCount = state.rowCount;\n state.rowCount = rowCount;\n state.colCount = colCount;\n\n // Update ARIA counts on inner grid element\n if (rowsBodyEl) {\n rowsBodyEl.setAttribute('aria-rowcount', String(rowCount));\n rowsBodyEl.setAttribute('aria-colcount', String(colCount));\n }\n\n // Set role=\"rowgroup\" on .rows only when there are rows (ARIA compliance)\n if (rowCount !== prevRowCount && bodyEl) {\n if (rowCount > 0) {\n bodyEl.setAttribute('role', 'rowgroup');\n } else {\n bodyEl.removeAttribute('role');\n }\n }\n\n return true;\n}\n\n// #endregion\n\n// #region Label Updates\n\n/**\n * Determine the effective aria-label for the grid.\n * Priority: explicit config > shell title > nothing\n *\n * The shell title is read from `config.shell.header.title`. The shell plugin\n * folds any light-DOM `<tbw-grid-header title>` into that field via its\n * `processConfig` hook (extraction #370), so core needs no plugin state here.\n *\n * @param config - Grid configuration (the merged effective config)\n * @returns The aria-label to use, or undefined\n */\nexport function getEffectiveAriaLabel<T>(config: GridConfig<T> | undefined): string | undefined {\n const explicitLabel = config?.gridAriaLabel;\n if (explicitLabel) return explicitLabel;\n\n return config?.shell?.header?.title ?? undefined;\n}\n\n/**\n * Update ARIA label and describedby attributes on the grid container.\n * Uses caching to avoid redundant DOM writes.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-label/aria-describedby on\n * @param config - Grid configuration (the merged effective config)\n * @returns true if anything was updated\n */\nexport function updateAriaLabels<T>(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n config: GridConfig<T> | undefined,\n): boolean {\n if (!rowsBodyEl) return false;\n\n let updated = false;\n\n // aria-labelledby wins per WAI-ARIA accessible-name precedence — when set,\n // `aria-label` is suppressed even if explicitly configured to avoid two\n // competing names being computed by AT.\n const ariaLabelledBy = config?.gridAriaLabelledBy;\n const effectiveLabel = ariaLabelledBy ? undefined : getEffectiveAriaLabel(config);\n\n // Update aria-labelledby only if changed\n if (ariaLabelledBy !== state.ariaLabelledBy) {\n state.ariaLabelledBy = ariaLabelledBy;\n if (ariaLabelledBy) {\n rowsBodyEl.setAttribute('aria-labelledby', ariaLabelledBy);\n } else {\n rowsBodyEl.removeAttribute('aria-labelledby');\n }\n updated = true;\n }\n\n // Update aria-label only if changed\n if (effectiveLabel !== state.ariaLabel) {\n state.ariaLabel = effectiveLabel;\n if (effectiveLabel) {\n rowsBodyEl.setAttribute('aria-label', effectiveLabel);\n } else {\n rowsBodyEl.removeAttribute('aria-label');\n }\n updated = true;\n }\n\n // Update aria-describedby only if changed\n const ariaDescribedBy = config?.gridAriaDescribedBy;\n if (ariaDescribedBy !== state.ariaDescribedBy) {\n state.ariaDescribedBy = ariaDescribedBy;\n if (ariaDescribedBy) {\n rowsBodyEl.setAttribute('aria-describedby', ariaDescribedBy);\n } else {\n rowsBodyEl.removeAttribute('aria-describedby');\n }\n updated = true;\n }\n\n // Update aria-roledescription only if changed. Apps can override the\n // announced role name (e.g. localized \"Tabell\" instead of generic \"grid\").\n const ariaRoleDescription = config?.gridAriaRoleDescription;\n if (ariaRoleDescription !== state.ariaRoleDescription) {\n state.ariaRoleDescription = ariaRoleDescription;\n if (ariaRoleDescription) {\n rowsBodyEl.setAttribute('aria-roledescription', ariaRoleDescription);\n } else {\n rowsBodyEl.removeAttribute('aria-roledescription');\n }\n updated = true;\n }\n\n return updated;\n}\n\n// #endregion\n\n// #region Live Announcements\n\n/**\n * Announce a message to screen readers via the grid's aria-live region.\n * Clears then sets text to ensure repeated messages are re-announced.\n *\n * Respects `effectiveConfig.a11y.announcements` — if set to `false`,\n * the announcement is silently skipped.\n *\n * @param gridEl - The grid host element (or any ancestor containing `.tbw-sr-only`)\n * @param message - The message to announce\n */\nexport function announce(gridEl: HTMLElement, message: string): void {\n // Check if announcements are disabled via config.\n // gridEl is always a DataGridElement at runtime — use property access safely.\n if (!gridEl) return;\n const config =\n 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n if (config?.a11y?.announcements === false) return;\n\n const el = gridEl.querySelector?.('.tbw-sr-only');\n if (!el) return;\n // Clear first so identical consecutive messages are still announced\n el.textContent = '';\n // `requestAnimationFrame` can be undefined if a debounced announcement fires\n // after the DOM/host environment is torn down (notably in unit tests). Fall\n // back to a direct assignment so `announce` never throws out of band.\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(() => {\n el.textContent = message;\n });\n } else {\n el.textContent = message;\n }\n}\n\n/**\n * Get an announcement message, preferring custom overrides from config.\n * Falls back to default English messages when no override is provided.\n *\n * @param gridEl - The grid host element\n * @param key - The message key from A11yMessages\n * @param args - Arguments to pass to the message function\n */\nexport function getA11yMessage<K extends keyof A11yMessages>(\n gridEl: HTMLElement,\n key: K,\n ...args: Parameters<Required<A11yMessages>[K]>\n): string {\n type MessageFn = (...a: Parameters<Required<A11yMessages>[K]>) => string;\n const config =\n gridEl && 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n const customFn = config?.a11y?.messages?.[key] as MessageFn | undefined;\n if (customFn) return customFn(...args);\n return (DEFAULT_A11Y_MESSAGES[key] as MessageFn)(...args);\n}\n\n/**\n * Announce a `dataLoaded` message — but only when the source row count has\n * actually changed since the last announcement. This prevents announcement\n * spam from internal `data-change` emits triggered by sort/filter/edit, which\n * have their own dedicated announcements.\n *\n * @param gridEl - The grid host element\n * @param state - ARIA state (tracks last announced count)\n * @param sourceRowCount - Current source row count\n */\nexport function announceDataLoaded(gridEl: HTMLElement, state: AriaState, sourceRowCount: number): void {\n if (sourceRowCount === state.lastAnnouncedSourceCount) return;\n const previousAnnouncedSourceCount = state.lastAnnouncedSourceCount;\n state.lastAnnouncedSourceCount = sourceRowCount;\n // Skip only the initial sentinel → 0 transition (empty grid mount). A later\n // transition to 0 (e.g. data cleared / reloaded to an empty result set) should\n // still announce so screen-reader users hear that the grid is now empty.\n if (sourceRowCount === 0 && previousAnnouncedSourceCount < 0) return;\n announce(gridEl, getA11yMessage(gridEl, 'dataLoaded', sourceRowCount));\n}\n\n// #endregion\n","/**\n * Multi-Sort Core Logic\n *\n * Pure functions for multi-column sorting operations.\n */\n\nimport { resolveCellValue } from '../../core/internal/value-accessor';\nimport type { ColumnConfig } from '../../core/types';\nimport type { SortModel } from './types';\n\n// Module-level cached collator. `String.prototype.localeCompare(b)` (no args)\n// lazily allocates a fresh Intl.Collator on every call in V8 — measurable on\n// large datasets where string comparisons dominate the secondary sort key.\n// A single cached `Intl.Collator` reused via `.compare(a, b)` is ~3-5x faster.\nconst stringCollator = new Intl.Collator(undefined, { sensitivity: 'variant' });\n\n/**\n * Apply multiple sort columns to a row array.\n * Sorts are applied in order - first sort has highest priority.\n *\n * **Non-mutating:** This function copies the input array before sorting and\n * returns the new array. Internal callers that already own a mutable copy\n * (e.g. inside `MultiSortPlugin.processRows`, where `plugin-manager` has already\n * cloned the row pipeline input) should call {@link sortRowsInPlace} directly\n * to avoid a redundant allocation in the hot path.\n *\n * @param rows - Array of row objects to sort\n * @param sorts - Ordered array of sort configurations\n * @param columns - Column configurations (for custom comparators)\n * @returns New sorted array (does not mutate original)\n */\nexport function applySorts<TRow = unknown>(rows: TRow[], sorts: SortModel[], columns: ColumnConfig<TRow>[]): TRow[] {\n if (!sorts.length) return [...rows];\n\n const copy = [...rows];\n sortRowsInPlace(copy, sorts, columns);\n return copy;\n}\n\n/**\n * Sort an array in-place using multiple sort columns.\n * Pre-resolves column comparators to avoid O(n·log·n·m) column lookups\n * inside the comparator.\n * @internal\n */\nexport function sortRowsInPlace<TRow = unknown>(rows: TRow[], sorts: SortModel[], columns: ColumnConfig<TRow>[]): void {\n if (!sorts.length) return;\n\n // Pre-resolve comparator chain — avoids columns.find() on every pair comparison\n const chain = sorts.map((sort) => {\n const col = columns.find((c) => c.field === sort.field);\n const comparator = col?.sortComparator ?? defaultComparator;\n // Pre-bind the value getter per link so the hot comparator path doesn't\n // re-evaluate `column?.valueAccessor` on every pair comparison. For the\n // common case (no valueAccessor) this collapses to a single property read.\n // Documented precedence: sortComparator → valueAccessor → field.\n const field = sort.field;\n const getValue: (row: TRow) => unknown = col?.valueAccessor\n ? (row: TRow) => resolveCellValue(row, col)\n : (row: TRow) => (row as Record<string, unknown>)[field];\n return {\n field,\n asc: sort.direction === 'asc',\n comparator,\n getValue,\n // Auto-pin `__loading` placeholder rows (e.g. ServerSidePlugin under `sortMode: 'local'`)\n // to the end ONLY when no custom comparator is configured. Custom comparators receive\n // the row pair as 3rd/4th args and own placeholder handling themselves.\n pinPlaceholders: !col?.sortComparator,\n };\n });\n\n // Only enable per-pair pin checks if the dataset *actually* contains loading\n // placeholders — for the common case (no `__loading` rows) we skip ~n·log·n\n // property reads + branch on the hot path. Single O(n) scan up front.\n const chainNeedsPin = chain.some((l) => l.pinPlaceholders);\n const needsPinScan = chainNeedsPin && hasLoadingRow(rows);\n\n // Schwartzian transform: extract sort keys ONCE per row up front, then sort\n // indices by the cached keys. `Array.prototype.sort` calls the comparator\n // ~n·log·n times; without caching, each call would re-extract every key\n // (2 sides × k columns = 2·k property reads / accessor invocations per\n // compare). Caching collapses ~2·k·n·log·n extractions to k·n. For 10K\n // rows × 2 keys that's 20K extractions instead of ~260K, and the comparator\n // body becomes a pure key-vs-key comparison the JIT can optimize tightly.\n const n = rows.length;\n const k = chain.length;\n const keys: unknown[] = new Array(n * k);\n for (let i = 0; i < n; i++) {\n const row = rows[i];\n const base = i * k;\n for (let j = 0; j < k; j++) {\n keys[base + j] = chain[j].getValue(row);\n }\n }\n\n // Index array — sort this and then permute `rows` in one pass at the end.\n // Typed array when it fits; falls back to plain Array for very large datasets.\n const indices: Uint32Array | number[] = n <= 0xffffffff ? new Uint32Array(n) : new Array(n);\n for (let i = 0; i < n; i++) indices[i] = i;\n\n const compareIndices = (ia: number, ib: number): number => {\n if (needsPinScan) {\n const pinned = pinLoadingRows(rows[ia], rows[ib]);\n if (pinned !== 0) return pinned;\n }\n const baseA = ia * k;\n const baseB = ib * k;\n for (let j = 0; j < k; j++) {\n const link = chain[j];\n // Custom comparators receive the row pair as 3rd/4th args; defaultComparator\n // ignores them. Keys are already cached so no extraction happens here.\n const result = link.comparator(keys[baseA + j], keys[baseB + j], rows[ia], rows[ib]);\n if (result !== 0) return link.asc ? result : -result;\n }\n return 0;\n };\n\n if (indices instanceof Uint32Array) {\n indices.sort(compareIndices);\n } else {\n (indices as number[]).sort(compareIndices);\n }\n\n // Permute rows in-place using the sorted index array. Single allocation of\n // a snapshot; then write back. Done in two passes to avoid clobbering.\n const snapshot = rows.slice();\n for (let i = 0; i < n; i++) {\n rows[i] = snapshot[indices[i]];\n }\n}\n\n/**\n * O(n) scan checking whether any row has the `__loading` placeholder marker.\n * Cheap relative to the O(n·log·n) sort, and lets us elide per-pair pin checks\n * when the dataset has no placeholders (the overwhelmingly common case).\n * @internal\n */\nfunction hasLoadingRow(rows: readonly unknown[]): boolean {\n for (let i = 0; i < rows.length; i++) {\n if ((rows[i] as { __loading?: unknown } | null)?.__loading === true) return true;\n }\n return false;\n}\n\n/**\n * Pin `__loading` placeholder rows (e.g. ServerSidePlugin under `sortMode: 'local'`)\n * to the end of the sorted array regardless of sort direction.\n *\n * Returns 0 when neither row is a placeholder, +1 when `a` is, -1 when `b` is.\n *\n * @internal\n */\nfunction pinLoadingRows(a: unknown, b: unknown): number {\n const aLoading = (a as { __loading?: unknown } | null)?.__loading === true;\n const bLoading = (b as { __loading?: unknown } | null)?.__loading === true;\n if (aLoading === bLoading) return 0;\n return aLoading ? 1 : -1;\n}\n\n/**\n * Default comparator for sorting values.\n * Handles nulls, numbers, dates, and strings.\n *\n * @param a - First value\n * @param b - Second value\n * @returns Comparison result (-1, 0, 1)\n */\nexport function defaultComparator(a: unknown, b: unknown): number {\n // Handle nulls/undefined - push to end\n if (a == null && b == null) return 0;\n if (a == null) return 1;\n if (b == null) return -1;\n\n // Type-aware comparison\n if (typeof a === 'number' && typeof b === 'number') {\n return a - b;\n }\n\n if (a instanceof Date && b instanceof Date) {\n return a.getTime() - b.getTime();\n }\n\n // Boolean comparison\n if (typeof a === 'boolean' && typeof b === 'boolean') {\n return a === b ? 0 : a ? -1 : 1;\n }\n\n // String comparison (fallback). Uses a module-level cached Intl.Collator\n // — see `stringCollator` declaration for rationale.\n return stringCollator.compare(typeof a === 'string' ? a : String(a), typeof b === 'string' ? b : String(b));\n}\n\n/**\n * Toggle sort state for a field.\n * With shift key: adds/toggles in multi-sort list\n * Without shift key: replaces entire sort with single column\n *\n * @param current - Current sort model\n * @param field - Field to toggle\n * @param shiftKey - Whether shift key is held (multi-sort mode)\n * @param maxColumns - Maximum columns allowed in sort\n * @returns New sort model\n */\nexport function toggleSort(current: SortModel[], field: string, shiftKey: boolean, maxColumns: number): SortModel[] {\n const existing = current.find((s) => s.field === field);\n\n if (shiftKey) {\n // Multi-sort: add/toggle in list\n if (existing) {\n if (existing.direction === 'asc') {\n // Flip to descending\n return current.map((s) => (s.field === field ? { ...s, direction: 'desc' as const } : s));\n } else {\n // Remove from sort\n return current.filter((s) => s.field !== field);\n }\n } else if (current.length < maxColumns) {\n // Add new sort column\n return [...current, { field, direction: 'asc' as const }];\n }\n // Max columns reached, return unchanged\n return current;\n } else {\n // Single sort: replace all\n if (existing?.direction === 'asc') {\n return [{ field, direction: 'desc' }];\n } else if (existing?.direction === 'desc') {\n return [];\n }\n return [{ field, direction: 'asc' }];\n }\n}\n\n/**\n * Get the sort index (1-based) for a field in the sort model.\n * Returns undefined if the field is not in the sort model.\n *\n * @param sortModel - Current sort model\n * @param field - Field to check\n * @returns 1-based index or undefined\n */\nexport function getSortIndex(sortModel: SortModel[], field: string): number | undefined {\n const index = sortModel.findIndex((s) => s.field === field);\n return index >= 0 ? index + 1 : undefined;\n}\n\n/**\n * Get the sort direction for a field in the sort model.\n *\n * @param sortModel - Current sort model\n * @param field - Field to check\n * @returns Sort direction or undefined if not sorted\n */\nexport function getSortDirection(sortModel: SortModel[], field: string): 'asc' | 'desc' | undefined {\n return sortModel.find((s) => s.field === field)?.direction;\n}\n","@layer tbw-plugins {\n .header-cell[data-sort='asc']::after {\n content: '↑';\n margin-left: var(--tbw-spacing-xs, 0.25em);\n opacity: 0.8;\n }\n .header-cell[data-sort='desc']::after {\n content: '↓';\n margin-left: var(--tbw-spacing-xs, 0.25em);\n opacity: 0.8;\n }\n .sort-indicator {\n margin-left: var(--tbw-spacing-xs, 0.25em);\n opacity: 0.8;\n }\n .sort-index {\n font-size: var(--tbw-font-size-2xs, 0.7em);\n background: var(--tbw-multi-sort-badge-bg, var(--tbw-color-panel-bg));\n color: var(--tbw-multi-sort-badge-color, var(--tbw-color-fg));\n border-radius: 50%;\n width: var(--tbw-multi-sort-badge-size, 1em);\n height: var(--tbw-multi-sort-badge-size, 1em);\n display: inline-flex;\n align-items: center;\n justify-content: center;\n margin-left: var(--tbw-spacing-xs, 0.125em);\n font-weight: 600;\n }\n} /* End @layer tbw-plugins */\n","/**\n * Multi-Sort Plugin (Class-based)\n *\n * Provides multi-column sorting capabilities for tbw-grid.\n * Supports shift+click for adding secondary sort columns.\n */\n\nimport { announce, getA11yMessage } from '../../core/internal/aria';\nimport { BaseGridPlugin, HeaderClickEvent, type PluginManifest, type PluginQuery } from '../../core/plugin/base-plugin';\nimport type { ColumnState, GridHost } from '../../core/types';\nimport { getSortDirection, getSortIndex, sortRowsInPlace, toggleSort } from './multi-sort';\nimport styles from './multi-sort.css?inline';\nimport type { MultiSortConfig, SortModel } from './types';\n\n/**\n * Multi-Sort Plugin for tbw-grid\n *\n * Enables sorting by multiple columns at once—hold Shift and click additional column\n * headers to build up a sort stack. Priority badges show the sort order, so users\n * always know which column takes precedence.\n *\n * ## Installation\n *\n * ```ts\n * import { MultiSortPlugin } from '@toolbox-web/grid/plugins/multi-sort';\n * ```\n *\n * ## Keyboard Shortcuts\n *\n * | Shortcut | Action |\n * |----------|--------|\n * | `Click header` | Sort by column (clears other sorts) |\n * | `Shift + Click` | Add column to multi-sort stack |\n * | `Ctrl + Click` | Toggle sort direction |\n *\n * ## Events\n *\n * | Event | Detail | Description |\n * |-------|--------|-------------|\n * | `sort-change` | `{ sortModel: SortModel[] }` | Fired when sort changes |\n *\n * @example Basic Multi-Column Sorting\n * ```ts\n * import { queryGrid } from '@toolbox-web/grid';\n * import { MultiSortPlugin } from '@toolbox-web/grid/plugins/multi-sort';\n *\n * const grid = queryGrid('tbw-grid');\n * grid.gridConfig = {\n * columns: [\n * { field: 'name', header: 'Name', sortable: true },\n * { field: 'department', header: 'Department', sortable: true },\n * { field: 'salary', header: 'Salary', type: 'number', sortable: true },\n * ],\n * plugins: [new MultiSortPlugin({ maxSortColumns: 3, showSortIndex: true })],\n * };\n *\n * grid.on('sort-change', ({ sortModel }) => {\n * console.log('Active sorts:', sortModel);\n * });\n * ```\n *\n * @example Initial Sort Configuration\n * ```ts\n * new MultiSortPlugin({\n * initialSort: [\n * { field: 'department', direction: 'asc' },\n * { field: 'salary', direction: 'desc' },\n * ],\n * })\n * ```\n *\n * @see {@link MultiSortConfig} for all configuration options\n * @see {@link SortModel} for the sort model structure\n *\n * @internal Extends BaseGridPlugin\n * @since 0.1.1\n */\nexport class MultiSortPlugin extends BaseGridPlugin<MultiSortConfig> {\n /**\n * Plugin manifest declaring query types this plugin responds to.\n * @internal\n */\n static override readonly manifest: PluginManifest = {\n queries: [\n { type: 'sort:get-model', description: 'Returns the current multi-sort model as SortModel[]' },\n { type: 'sort:set-model', description: 'Sets the multi-sort model from context (SortModel[])' },\n ],\n };\n\n /** @internal */\n readonly name = 'multiSort';\n /** @internal */\n override readonly styles = styles;\n\n /** @internal */\n protected override get defaultConfig(): Partial<MultiSortConfig> {\n return {\n maxSortColumns: 3,\n showSortIndex: true,\n };\n }\n\n // #region Internal State\n private sortModel: SortModel[] = [];\n /** Cached sort result — returned as-is while a row edit is active to prevent\n * the edited row from jumping to a new sorted position mid-edit. Row data\n * mutations are still visible because the array holds shared object refs. */\n private cachedSortResult: unknown[] | null = null;\n /** Guards deferred sort-change broadcast during applyColumnState batch. */\n #pendingStateBroadcast = false;\n /** Snapshot of sortModel taken at the start of an applyColumnState batch,\n * used by the deferred broadcast to skip when the model didn't change. */\n #preBatchSortModel: SortModel[] = [];\n\n /** Typed internal grid accessor. */\n get #internalGrid(): GridHost {\n return this.grid;\n }\n\n /**\n * Clear the core `_sortState` so that only this plugin's `processRows`\n * sorting applies. `ConfigManager.applyState()` always sets the core sort\n * state when restoring from storage, even when a plugin handles sorting.\n * Without this, the stale core state leaks into `collectState()` and\n * `reapplyCoreSort()` after the plugin clears its own model.\n */\n private clearCoreSortState(): void {\n this.#internalGrid._sortState = null;\n }\n\n /**\n * Remove sorts on fields that are owned by the grouping plugin.\n * GroupingRowsPlugin handles group header ordering independently, so\n * multi-sort should only sort by non-grouped data columns.\n */\n #filterGroupedFields(model: SortModel[]): SortModel[] {\n const results = this.grid?.query?.('grouping:get-grouped-fields', null);\n if (!Array.isArray(results) || results.length === 0) return model;\n\n const groupedFields = results[0] as string[];\n if (!Array.isArray(groupedFields) || groupedFields.length === 0) return model;\n\n const groupedSet = new Set(groupedFields);\n const filtered = model.filter((s) => !groupedSet.has(s.field));\n return filtered.length === model.length ? model : filtered;\n }\n // #endregion\n\n // #region Lifecycle\n\n /** @internal */\n override detach(): void {\n this.sortModel = [];\n this.cachedSortResult = null;\n }\n // #endregion\n\n // #region Query System\n\n /** @internal */\n override handleQuery(query: PluginQuery): unknown {\n switch (query.type) {\n case 'sort:get-model':\n return [...this.sortModel];\n case 'sort:set-model': {\n const model = query.context;\n if (!Array.isArray(model)) return false;\n this.sortModel = [...model] as SortModel[];\n this.clearCoreSortState();\n this.broadcast('sort-change', { sortModel: [...this.sortModel] });\n this.requestRender();\n return true;\n }\n default:\n return undefined;\n }\n }\n\n // #endregion\n\n // #region Hooks\n\n /** @internal */\n override processRows(rows: readonly unknown[]): unknown[] {\n if (this.sortModel.length === 0) {\n this.cachedSortResult = null;\n return [...rows];\n }\n\n // Freeze sort order while a row is actively being edited (row mode only).\n // Re-sorting mid-edit would move the edited row to a new index while the\n // editors remain at the old position, causing data/UI mismatch.\n // In grid mode (_isGridEditMode) sorting is safe — afterCellRender\n // re-injects editors into the re-sorted cells.\n // We return the cached previous sort result (same object references, so\n // in-place value mutations are already visible) instead of unsorted input.\n const grid = this.#internalGrid;\n if (!grid._isGridEditMode && typeof grid._activeEditRows === 'number' && grid._activeEditRows !== -1) {\n if (this.cachedSortResult && this.cachedSortResult.length === rows.length) {\n return [...this.cachedSortResult];\n }\n }\n\n // Sort in-place — the input array is already a mutable copy from plugin-manager.\n // Pre-resolved comparator chain avoids column lookup on every pair comparison.\n // Exclude fields owned by the grouping plugin — group header order is handled\n // by GroupingRowsPlugin, so multi-sort should only affect within-group data order.\n const effectiveModel = this.#filterGroupedFields(this.sortModel);\n\n const mutableRows = rows as unknown[];\n if (effectiveModel.length > 0) {\n sortRowsInPlace(mutableRows, effectiveModel, this.columns);\n }\n this.cachedSortResult = mutableRows;\n return mutableRows;\n }\n\n /** @internal */\n override onHeaderClick(event: HeaderClickEvent): boolean {\n const column = this.columns.find((c) => c.field === event.field);\n if (!column?.sortable) return false;\n\n const shiftKey = event.originalEvent.shiftKey;\n const maxColumns = this.config.maxSortColumns ?? 3;\n\n this.sortModel = toggleSort(this.sortModel, event.field, shiftKey, maxColumns);\n this.clearCoreSortState();\n\n this.broadcast('sort-change', { sortModel: [...this.sortModel] });\n this.requestRender();\n this.grid?.requestStateChange?.();\n\n // Announce for screen readers\n if (this.sortModel.length > 0) {\n const labels = this.sortModel.map((s) => {\n const col = this.columns.find((c) => c.field === s.field);\n return `${col?.header ?? s.field} ${s.direction === 'asc' ? 'ascending' : 'descending'}`;\n });\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortApplied', labels.join(', then '), ''));\n } else {\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortCleared'));\n }\n\n return true;\n }\n\n /** @internal */\n override afterRender(): void {\n const gridEl = this.gridElement;\n if (!gridEl) return;\n\n const showIndex = this.config.showSortIndex !== false;\n\n const headerCells = gridEl.querySelectorAll('.header-row .cell[data-field]');\n headerCells.forEach((cell) => {\n const field = cell.getAttribute('data-field');\n if (!field) return;\n\n const sortIndex = getSortIndex(this.sortModel, field);\n const sortDir = getSortDirection(this.sortModel, field);\n\n // Remove existing sort index badge (always clean up)\n cell.querySelector('.sort-index')?.remove();\n\n if (sortDir) {\n const indicator = this.updateSortIndicator(cell, sortDir);\n\n // Add sort index badge if multiple columns sorted and showSortIndex is enabled\n if (showIndex && this.sortModel.length > 1 && sortIndex !== undefined) {\n const badge = document.createElement('span');\n badge.className = 'sort-index';\n badge.textContent = String(sortIndex);\n if (indicator.nextSibling) {\n cell.insertBefore(badge, indicator.nextSibling);\n } else {\n cell.appendChild(badge);\n }\n }\n } else if (cell.classList.contains('sortable')) {\n this.updateSortIndicator(cell, null);\n }\n });\n }\n // #endregion\n\n // #region Public API\n\n /**\n * Get the current sort model.\n * @returns Copy of the current sort model\n */\n getSortModel(): SortModel[] {\n return [...this.sortModel];\n }\n\n /**\n * Set the sort model programmatically.\n * @param model - New sort model to apply\n */\n setSortModel(model: SortModel[]): void {\n this.sortModel = [...model];\n this.clearCoreSortState();\n this.broadcast('sort-change', { sortModel: [...model] });\n this.requestRender();\n this.grid?.requestStateChange?.();\n if (model.length > 0) {\n const labels = model.map((s) => {\n const col = this.columns.find((c) => c.field === s.field);\n return `${col?.header ?? s.field} ${s.direction === 'asc' ? 'ascending' : 'descending'}`;\n });\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortApplied', labels.join(', then '), ''));\n }\n }\n\n /**\n * Clear all sorting.\n */\n clearSort(): void {\n this.sortModel = [];\n this.clearCoreSortState();\n this.broadcast('sort-change', { sortModel: [] });\n this.requestRender();\n this.grid?.requestStateChange?.();\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortCleared'));\n }\n\n /**\n * Get the sort index (1-based) for a specific field.\n * @param field - Field to check\n * @returns 1-based index or undefined if not sorted\n */\n getSortIndex(field: string): number | undefined {\n return getSortIndex(this.sortModel, field);\n }\n\n /**\n * Get the sort direction for a specific field.\n * @param field - Field to check\n * @returns Sort direction or undefined if not sorted\n */\n getSortDirection(field: string): 'asc' | 'desc' | undefined {\n return getSortDirection(this.sortModel, field);\n }\n // #endregion\n\n // #region Column State Hooks\n\n /**\n * Return sort state for a column if it's in the sort model.\n * @internal\n */\n override getColumnState(field: string): Partial<ColumnState> | undefined {\n const index = this.sortModel.findIndex((s) => s.field === field);\n if (index === -1) return undefined;\n\n const sortEntry = this.sortModel[index];\n return {\n sort: {\n direction: sortEntry.direction,\n priority: index,\n },\n };\n }\n\n /**\n * Apply sort state from column state.\n * Rebuilds the sort model from all column states.\n * @internal\n */\n override applyColumnState(field: string, state: ColumnState): void {\n // Snapshot model before mutation so the microtask can skip a no-op\n // broadcast/render when nothing actually changed.\n if (!this.#pendingStateBroadcast) {\n this.#preBatchSortModel = [...this.sortModel];\n }\n\n if (!state.sort) {\n // Remove this field from sortModel if it exists\n this.sortModel = this.sortModel.filter((s) => s.field !== field);\n } else {\n // Find existing entry or add new one\n const existingIndex = this.sortModel.findIndex((s) => s.field === field);\n const newEntry: SortModel = {\n field,\n direction: state.sort.direction,\n };\n\n if (existingIndex !== -1) {\n // Update existing entry\n this.sortModel[existingIndex] = newEntry;\n } else {\n // Add at the correct priority position\n this.sortModel.splice(state.sort.priority, 0, newEntry);\n }\n }\n\n // Clear core sort state — this plugin exclusively handles sorting via\n // processRows. The core _sortState is set by ConfigManager.applyState()\n // before plugins run; null it so reapplyCoreSort() is a no-op.\n this.clearCoreSortState();\n\n // Broadcast sort-change + force a row re-render after state restoration.\n // Both are deferred to a microtask so per-column calls coalesce into one\n // event and one render. The render handles the \"sort cleared\" case that\n // core's width-only fast path cannot detect (core only inspects the\n // *incoming* state, not what the plugin's model became). Skip entirely\n // when the model is unchanged so unrelated state restores stay cheap.\n if (!this.#pendingStateBroadcast) {\n this.#pendingStateBroadcast = true;\n queueMicrotask(() => {\n this.#pendingStateBroadcast = false;\n const prev = this.#preBatchSortModel;\n this.#preBatchSortModel = [];\n if (sortModelEquals(prev, this.sortModel)) return;\n this.broadcast('sort-change', { sortModel: [...this.sortModel] });\n this.requestRender();\n });\n }\n }\n // #endregion\n}\n\n/** Shallow equality for two sort models (order-sensitive). */\nfunction sortModelEquals(a: SortModel[], b: SortModel[]): boolean {\n if (a.length !== b.length) return false;\n for (let i = 0; i < a.length; i++) {\n if (a[i].field !== b[i].field || a[i].direction !== b[i].direction) return false;\n }\n return true;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../../../../../../libs/grid/src/lib/core/internal/aria.ts","../../../../../../libs/grid/src/lib/plugins/multi-sort/multi-sort.ts","../../../../../../libs/grid/src/lib/plugins/multi-sort/multi-sort.css?inline","../../../../../../libs/grid/src/lib/plugins/multi-sort/multi-sort-plugin.ts"],"sourcesContent":["/**\n * ARIA Accessibility Helpers\n *\n * Pure functions for managing ARIA attributes on grid elements.\n * Implements caching to avoid redundant DOM writes during scroll.\n *\n * @module internal/aria\n */\n\nimport { DEFAULT_A11Y_MESSAGES } from '../defaults';\nimport type { A11yMessages, GridConfig } from '../types';\n\n// #region Types\n\n/**\n * State for caching ARIA attributes to avoid redundant DOM writes.\n */\nexport interface AriaState {\n /** Last set row count */\n rowCount: number;\n /** Last set column count */\n colCount: number;\n /** Last set aria-label */\n ariaLabel: string | undefined;\n /** Last set aria-labelledby */\n ariaLabelledBy: string | undefined;\n /** Last set aria-describedby */\n ariaDescribedBy: string | undefined;\n /** Last set aria-roledescription */\n ariaRoleDescription: string | undefined;\n /** Last source row count announced via `dataLoaded`; used to suppress duplicate announcements */\n lastAnnouncedSourceCount: number;\n}\n\n/**\n * Create initial ARIA state.\n */\nexport function createAriaState(): AriaState {\n return {\n rowCount: -1,\n colCount: -1,\n ariaLabel: undefined,\n ariaLabelledBy: undefined,\n ariaDescribedBy: undefined,\n ariaRoleDescription: undefined,\n lastAnnouncedSourceCount: -1,\n };\n}\n\n// #endregion\n\n// #region Count Updates\n\n/**\n * Update ARIA row and column counts on grid elements.\n * Uses caching to avoid redundant DOM writes on every scroll frame.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-rowcount/aria-colcount on\n * @param bodyEl - Element to set role=\"rowgroup\" on\n * @param rowCount - Current row count\n * @param colCount - Current column count\n * @returns true if anything was updated\n */\nexport function updateAriaCounts(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n bodyEl: HTMLElement | null,\n rowCount: number,\n colCount: number,\n): boolean {\n // Skip if nothing changed (hot path optimization for scroll)\n if (rowCount === state.rowCount && colCount === state.colCount) {\n return false;\n }\n\n const prevRowCount = state.rowCount;\n state.rowCount = rowCount;\n state.colCount = colCount;\n\n // Update ARIA counts on inner grid element\n if (rowsBodyEl) {\n rowsBodyEl.setAttribute('aria-rowcount', String(rowCount));\n rowsBodyEl.setAttribute('aria-colcount', String(colCount));\n }\n\n // Set role=\"rowgroup\" on .rows only when there are rows (ARIA compliance)\n if (rowCount !== prevRowCount && bodyEl) {\n if (rowCount > 0) {\n bodyEl.setAttribute('role', 'rowgroup');\n } else {\n bodyEl.removeAttribute('role');\n }\n }\n\n return true;\n}\n\n// #endregion\n\n// #region Label Updates\n\n/**\n * Determine the effective aria-label for the grid.\n * Priority: explicit config > shell title > nothing\n *\n * The shell title is read from `config.shell.header.title`. The shell plugin\n * folds any light-DOM `<tbw-grid-header title>` into that field via its\n * `processConfig` hook (extraction #370), so core needs no plugin state here.\n *\n * @param config - Grid configuration (the merged effective config)\n * @returns The aria-label to use, or undefined\n */\nexport function getEffectiveAriaLabel<T>(config: GridConfig<T> | undefined): string | undefined {\n const explicitLabel = config?.gridAriaLabel;\n if (explicitLabel) return explicitLabel;\n\n return config?.shell?.header?.title ?? undefined;\n}\n\n/**\n * Update ARIA label and describedby attributes on the grid container.\n * Uses caching to avoid redundant DOM writes.\n *\n * @param state - ARIA state for caching\n * @param rowsBodyEl - Element to set aria-label/aria-describedby on\n * @param config - Grid configuration (the merged effective config)\n * @returns true if anything was updated\n */\nexport function updateAriaLabels<T>(\n state: AriaState,\n rowsBodyEl: HTMLElement | null,\n config: GridConfig<T> | undefined,\n): boolean {\n if (!rowsBodyEl) return false;\n\n let updated = false;\n\n // aria-labelledby wins per WAI-ARIA accessible-name precedence — when set,\n // `aria-label` is suppressed even if explicitly configured to avoid two\n // competing names being computed by AT.\n const ariaLabelledBy = config?.gridAriaLabelledBy;\n const effectiveLabel = ariaLabelledBy ? undefined : getEffectiveAriaLabel(config);\n\n // Update aria-labelledby only if changed\n if (ariaLabelledBy !== state.ariaLabelledBy) {\n state.ariaLabelledBy = ariaLabelledBy;\n if (ariaLabelledBy) {\n rowsBodyEl.setAttribute('aria-labelledby', ariaLabelledBy);\n } else {\n rowsBodyEl.removeAttribute('aria-labelledby');\n }\n updated = true;\n }\n\n // Update aria-label only if changed\n if (effectiveLabel !== state.ariaLabel) {\n state.ariaLabel = effectiveLabel;\n if (effectiveLabel) {\n rowsBodyEl.setAttribute('aria-label', effectiveLabel);\n } else {\n rowsBodyEl.removeAttribute('aria-label');\n }\n updated = true;\n }\n\n // Update aria-describedby only if changed\n const ariaDescribedBy = config?.gridAriaDescribedBy;\n if (ariaDescribedBy !== state.ariaDescribedBy) {\n state.ariaDescribedBy = ariaDescribedBy;\n if (ariaDescribedBy) {\n rowsBodyEl.setAttribute('aria-describedby', ariaDescribedBy);\n } else {\n rowsBodyEl.removeAttribute('aria-describedby');\n }\n updated = true;\n }\n\n // Update aria-roledescription only if changed. Apps can override the\n // announced role name (e.g. localized \"Tabell\" instead of generic \"grid\").\n const ariaRoleDescription = config?.gridAriaRoleDescription;\n if (ariaRoleDescription !== state.ariaRoleDescription) {\n state.ariaRoleDescription = ariaRoleDescription;\n if (ariaRoleDescription) {\n rowsBodyEl.setAttribute('aria-roledescription', ariaRoleDescription);\n } else {\n rowsBodyEl.removeAttribute('aria-roledescription');\n }\n updated = true;\n }\n\n return updated;\n}\n\n// #endregion\n\n// #region Live Announcements\n\n/**\n * Announce a message to screen readers via the grid's aria-live region.\n * Clears then sets text to ensure repeated messages are re-announced.\n *\n * Respects `effectiveConfig.a11y.announcements` — if set to `false`,\n * the announcement is silently skipped.\n *\n * @param gridEl - The grid host element (or any ancestor containing `.tbw-sr-only`)\n * @param message - The message to announce\n */\nexport function announce(gridEl: HTMLElement, message: string): void {\n // Check if announcements are disabled via config.\n // gridEl is always a DataGridElement at runtime — use property access safely.\n if (!gridEl) return;\n const config =\n 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n if (config?.a11y?.announcements === false) return;\n\n const el = gridEl.querySelector?.('.tbw-sr-only');\n if (!el) return;\n // Clear first so identical consecutive messages are still announced\n el.textContent = '';\n // `requestAnimationFrame` can be undefined if a debounced announcement fires\n // after the DOM/host environment is torn down (notably in unit tests). Fall\n // back to a direct assignment so `announce` never throws out of band.\n if (typeof requestAnimationFrame === 'function') {\n requestAnimationFrame(() => {\n el.textContent = message;\n });\n } else {\n el.textContent = message;\n }\n}\n\n/**\n * Get an announcement message, preferring custom overrides from config.\n * Falls back to default English messages when no override is provided.\n *\n * @param gridEl - The grid host element\n * @param key - The message key from A11yMessages\n * @param args - Arguments to pass to the message function\n */\nexport function getA11yMessage<K extends keyof A11yMessages>(\n gridEl: HTMLElement,\n key: K,\n ...args: Parameters<Required<A11yMessages>[K]>\n): string {\n type MessageFn = (...a: Parameters<Required<A11yMessages>[K]>) => string;\n const config =\n gridEl && 'effectiveConfig' in gridEl\n ? (gridEl as HTMLElement & { effectiveConfig?: GridConfig }).effectiveConfig\n : undefined;\n const customFn = config?.a11y?.messages?.[key] as MessageFn | undefined;\n if (customFn) return customFn(...args);\n return (DEFAULT_A11Y_MESSAGES[key] as MessageFn)(...args);\n}\n\n/**\n * Announce a `dataLoaded` message — but only when the source row count has\n * actually changed since the last announcement. This prevents announcement\n * spam from internal `data-change` emits triggered by sort/filter/edit, which\n * have their own dedicated announcements.\n *\n * @param gridEl - The grid host element\n * @param state - ARIA state (tracks last announced count)\n * @param sourceRowCount - Current source row count\n */\nexport function announceDataLoaded(gridEl: HTMLElement, state: AriaState, sourceRowCount: number): void {\n if (sourceRowCount === state.lastAnnouncedSourceCount) return;\n const previousAnnouncedSourceCount = state.lastAnnouncedSourceCount;\n state.lastAnnouncedSourceCount = sourceRowCount;\n // Skip only the initial sentinel → 0 transition (empty grid mount). A later\n // transition to 0 (e.g. data cleared / reloaded to an empty result set) should\n // still announce so screen-reader users hear that the grid is now empty.\n if (sourceRowCount === 0 && previousAnnouncedSourceCount < 0) return;\n announce(gridEl, getA11yMessage(gridEl, 'dataLoaded', sourceRowCount));\n}\n\n// #endregion\n","/**\n * Multi-Sort Core Logic\n *\n * Pure functions for multi-column sorting operations.\n */\n\nimport { resolveCellValue } from '../../core/internal/value-accessor';\nimport type { ColumnConfig } from '../../core/types';\nimport type { SortModel } from './types';\n\n// Module-level cached collator. `String.prototype.localeCompare(b)` (no args)\n// lazily allocates a fresh Intl.Collator on every call in V8 — measurable on\n// large datasets where string comparisons dominate the secondary sort key.\n// A single cached `Intl.Collator` reused via `.compare(a, b)` is ~3-5x faster.\nconst stringCollator = new Intl.Collator(undefined, { sensitivity: 'variant' });\n\n/**\n * Apply multiple sort columns to a row array.\n * Sorts are applied in order - first sort has highest priority.\n *\n * **Non-mutating:** This function copies the input array before sorting and\n * returns the new array. Internal callers that already own a mutable copy\n * (e.g. inside `MultiSortPlugin.processRows`, where `plugin-manager` has already\n * cloned the row pipeline input) should call {@link sortRowsInPlace} directly\n * to avoid a redundant allocation in the hot path.\n *\n * @param rows - Array of row objects to sort\n * @param sorts - Ordered array of sort configurations\n * @param columns - Column configurations (for custom comparators)\n * @returns New sorted array (does not mutate original)\n */\nexport function applySorts<TRow = unknown>(rows: TRow[], sorts: SortModel[], columns: ColumnConfig<TRow>[]): TRow[] {\n if (!sorts.length) return [...rows];\n\n const copy = [...rows];\n sortRowsInPlace(copy, sorts, columns);\n return copy;\n}\n\n/**\n * Sort an array in-place using multiple sort columns.\n * Pre-resolves column comparators to avoid O(n·log·n·m) column lookups\n * inside the comparator.\n * @internal\n */\nexport function sortRowsInPlace<TRow = unknown>(rows: TRow[], sorts: SortModel[], columns: ColumnConfig<TRow>[]): void {\n if (!sorts.length) return;\n\n // Pre-resolve comparator chain — avoids columns.find() on every pair comparison\n const chain = sorts.map((sort) => {\n const col = columns.find((c) => c.field === sort.field);\n const comparator = col?.sortComparator ?? defaultComparator;\n // Pre-bind the value getter per link so the hot comparator path doesn't\n // re-evaluate `column?.valueAccessor` on every pair comparison. For the\n // common case (no valueAccessor) this collapses to a single property read.\n // Documented precedence: sortComparator → valueAccessor → field.\n const field = sort.field;\n const getValue: (row: TRow) => unknown = col?.valueAccessor\n ? (row: TRow) => resolveCellValue(row, col)\n : (row: TRow) => (row as Record<string, unknown>)[field];\n return {\n field,\n asc: sort.direction === 'asc',\n comparator,\n getValue,\n // Auto-pin `__loading` placeholder rows (e.g. ServerSidePlugin under `sortMode: 'local'`)\n // to the end ONLY when no custom comparator is configured. Custom comparators receive\n // the row pair as 3rd/4th args and own placeholder handling themselves.\n pinPlaceholders: !col?.sortComparator,\n };\n });\n\n // Only enable per-pair pin checks if the dataset *actually* contains loading\n // placeholders — for the common case (no `__loading` rows) we skip ~n·log·n\n // property reads + branch on the hot path. Single O(n) scan up front.\n const chainNeedsPin = chain.some((l) => l.pinPlaceholders);\n const needsPinScan = chainNeedsPin && hasLoadingRow(rows);\n\n // Schwartzian transform: extract sort keys ONCE per row up front, then sort\n // indices by the cached keys. `Array.prototype.sort` calls the comparator\n // ~n·log·n times; without caching, each call would re-extract every key\n // (2 sides × k columns = 2·k property reads / accessor invocations per\n // compare). Caching collapses ~2·k·n·log·n extractions to k·n. For 10K\n // rows × 2 keys that's 20K extractions instead of ~260K, and the comparator\n // body becomes a pure key-vs-key comparison the JIT can optimize tightly.\n const n = rows.length;\n const k = chain.length;\n const keys: unknown[] = new Array(n * k);\n for (let i = 0; i < n; i++) {\n const row = rows[i];\n const base = i * k;\n for (let j = 0; j < k; j++) {\n keys[base + j] = chain[j].getValue(row);\n }\n }\n\n // Index array — sort this and then permute `rows` in one pass at the end.\n // Typed array when it fits; falls back to plain Array for very large datasets.\n const indices: Uint32Array | number[] = n <= 0xffffffff ? new Uint32Array(n) : new Array(n);\n for (let i = 0; i < n; i++) indices[i] = i;\n\n const compareIndices = (ia: number, ib: number): number => {\n if (needsPinScan) {\n const pinned = pinLoadingRows(rows[ia], rows[ib]);\n if (pinned !== 0) return pinned;\n }\n const baseA = ia * k;\n const baseB = ib * k;\n for (let j = 0; j < k; j++) {\n const link = chain[j];\n // Custom comparators receive the row pair as 3rd/4th args; defaultComparator\n // ignores them. Keys are already cached so no extraction happens here.\n const result = link.comparator(keys[baseA + j], keys[baseB + j], rows[ia], rows[ib]);\n if (result !== 0) return link.asc ? result : -result;\n }\n return 0;\n };\n\n if (indices instanceof Uint32Array) {\n indices.sort(compareIndices);\n } else {\n (indices as number[]).sort(compareIndices);\n }\n\n // Permute rows in-place using the sorted index array. Single allocation of\n // a snapshot; then write back. Done in two passes to avoid clobbering.\n const snapshot = rows.slice();\n for (let i = 0; i < n; i++) {\n rows[i] = snapshot[indices[i]];\n }\n}\n\n/**\n * O(n) scan checking whether any row has the `__loading` placeholder marker.\n * Cheap relative to the O(n·log·n) sort, and lets us elide per-pair pin checks\n * when the dataset has no placeholders (the overwhelmingly common case).\n * @internal\n */\nfunction hasLoadingRow(rows: readonly unknown[]): boolean {\n for (let i = 0; i < rows.length; i++) {\n if ((rows[i] as { __loading?: unknown } | null)?.__loading === true) return true;\n }\n return false;\n}\n\n/**\n * Pin `__loading` placeholder rows (e.g. ServerSidePlugin under `sortMode: 'local'`)\n * to the end of the sorted array regardless of sort direction.\n *\n * Returns 0 when neither row is a placeholder, +1 when `a` is, -1 when `b` is.\n *\n * @internal\n */\nfunction pinLoadingRows(a: unknown, b: unknown): number {\n const aLoading = (a as { __loading?: unknown } | null)?.__loading === true;\n const bLoading = (b as { __loading?: unknown } | null)?.__loading === true;\n if (aLoading === bLoading) return 0;\n return aLoading ? 1 : -1;\n}\n\n/**\n * Default comparator for sorting values.\n * Handles nulls, numbers, dates, and strings.\n *\n * @param a - First value\n * @param b - Second value\n * @returns Comparison result (-1, 0, 1)\n */\nexport function defaultComparator(a: unknown, b: unknown): number {\n // Handle nulls/undefined - push to end\n if (a == null && b == null) return 0;\n if (a == null) return 1;\n if (b == null) return -1;\n\n // Type-aware comparison\n if (typeof a === 'number' && typeof b === 'number') {\n return a - b;\n }\n\n if (a instanceof Date && b instanceof Date) {\n return a.getTime() - b.getTime();\n }\n\n // Boolean comparison\n if (typeof a === 'boolean' && typeof b === 'boolean') {\n return a === b ? 0 : a ? -1 : 1;\n }\n\n // String comparison (fallback). Uses a module-level cached Intl.Collator\n // — see `stringCollator` declaration for rationale.\n return stringCollator.compare(typeof a === 'string' ? a : String(a), typeof b === 'string' ? b : String(b));\n}\n\n/**\n * Toggle sort state for a field.\n * With shift key: adds/toggles in multi-sort list\n * Without shift key: replaces entire sort with single column\n *\n * @param current - Current sort model\n * @param field - Field to toggle\n * @param shiftKey - Whether shift key is held (multi-sort mode)\n * @param maxColumns - Maximum columns allowed in sort\n * @returns New sort model\n */\nexport function toggleSort(current: SortModel[], field: string, shiftKey: boolean, maxColumns: number): SortModel[] {\n const existing = current.find((s) => s.field === field);\n\n if (shiftKey) {\n // Multi-sort: add/toggle in list\n if (existing) {\n if (existing.direction === 'asc') {\n // Flip to descending\n return current.map((s) => (s.field === field ? { ...s, direction: 'desc' as const } : s));\n } else {\n // Remove from sort\n return current.filter((s) => s.field !== field);\n }\n } else if (current.length < maxColumns) {\n // Add new sort column\n return [...current, { field, direction: 'asc' as const }];\n }\n // Max columns reached, return unchanged\n return current;\n } else {\n // Single sort: replace all\n if (existing?.direction === 'asc') {\n return [{ field, direction: 'desc' }];\n } else if (existing?.direction === 'desc') {\n return [];\n }\n return [{ field, direction: 'asc' }];\n }\n}\n\n/**\n * Get the sort index (1-based) for a field in the sort model.\n * Returns undefined if the field is not in the sort model.\n *\n * @param sortModel - Current sort model\n * @param field - Field to check\n * @returns 1-based index or undefined\n */\nexport function getSortIndex(sortModel: SortModel[], field: string): number | undefined {\n const index = sortModel.findIndex((s) => s.field === field);\n return index >= 0 ? index + 1 : undefined;\n}\n\n/**\n * Get the sort direction for a field in the sort model.\n *\n * @param sortModel - Current sort model\n * @param field - Field to check\n * @returns Sort direction or undefined if not sorted\n */\nexport function getSortDirection(sortModel: SortModel[], field: string): 'asc' | 'desc' | undefined {\n return sortModel.find((s) => s.field === field)?.direction;\n}\n","@layer tbw-plugins {\n .header-cell[data-sort='asc']::after {\n content: '↑';\n margin-left: var(--tbw-spacing-xs, 0.25em);\n opacity: 0.8;\n }\n .header-cell[data-sort='desc']::after {\n content: '↓';\n margin-left: var(--tbw-spacing-xs, 0.25em);\n opacity: 0.8;\n }\n .sort-indicator {\n margin-left: var(--tbw-spacing-xs, 0.25em);\n opacity: 0.8;\n }\n .sort-index {\n font-size: var(--tbw-font-size-2xs, 0.7em);\n background: var(--tbw-multi-sort-badge-bg, var(--tbw-color-panel-bg));\n color: var(--tbw-multi-sort-badge-color, var(--tbw-color-fg));\n border-radius: 50%;\n width: var(--tbw-multi-sort-badge-size, 1em);\n height: var(--tbw-multi-sort-badge-size, 1em);\n display: inline-flex;\n align-items: center;\n justify-content: center;\n margin-left: var(--tbw-spacing-xs, 0.125em);\n font-weight: 600;\n }\n} /* End @layer tbw-plugins */\n","/**\n * Multi-Sort Plugin (Class-based)\n *\n * Provides multi-column sorting capabilities for tbw-grid.\n * Supports shift+click for adding secondary sort columns.\n */\n\nimport { announce, getA11yMessage } from '../../core/internal/aria';\nimport { BaseGridPlugin, HeaderClickEvent, type PluginManifest, type PluginQuery } from '../../core/plugin/base-plugin';\nimport type { ColumnState, GridHost } from '../../core/types';\nimport { getSortDirection, getSortIndex, sortRowsInPlace, toggleSort } from './multi-sort';\nimport styles from './multi-sort.css?inline';\nimport type { MultiSortConfig, SortModel } from './types';\n\n/**\n * Multi-Sort Plugin for tbw-grid\n *\n * Enables sorting by multiple columns at once—hold Shift and click additional column\n * headers to build up a sort stack. Priority badges show the sort order, so users\n * always know which column takes precedence.\n *\n * ## Installation\n *\n * ```ts\n * import { MultiSortPlugin } from '@toolbox-web/grid/plugins/multi-sort';\n * ```\n *\n * ## Keyboard Shortcuts\n *\n * | Shortcut | Action |\n * |----------|--------|\n * | `Click header` | Sort by column (clears other sorts) |\n * | `Shift + Click` | Add column to multi-sort stack |\n * | `Ctrl + Click` | Toggle sort direction |\n *\n * ## Events\n *\n * | Event | Detail | Description |\n * |-------|--------|-------------|\n * | `sort-change` | `{ sortModel: SortModel[] }` | Fired when sort changes |\n *\n * @example Basic Multi-Column Sorting\n * ```ts\n * import { queryGrid } from '@toolbox-web/grid';\n * import { MultiSortPlugin } from '@toolbox-web/grid/plugins/multi-sort';\n *\n * const grid = queryGrid('tbw-grid');\n * grid.gridConfig = {\n * columns: [\n * { field: 'name', header: 'Name', sortable: true },\n * { field: 'department', header: 'Department', sortable: true },\n * { field: 'salary', header: 'Salary', type: 'number', sortable: true },\n * ],\n * plugins: [new MultiSortPlugin({ maxSortColumns: 3, showSortIndex: true })],\n * };\n *\n * grid.on('sort-change', ({ sortModel }) => {\n * console.log('Active sorts:', sortModel);\n * });\n * ```\n *\n * @example Initial Sort Configuration\n * ```ts\n * new MultiSortPlugin({\n * initialSort: [\n * { field: 'department', direction: 'asc' },\n * { field: 'salary', direction: 'desc' },\n * ],\n * })\n * ```\n *\n * @see {@link MultiSortConfig} for all configuration options\n * @see {@link SortModel} for the sort model structure\n *\n * @internal Extends BaseGridPlugin\n * @since 0.1.1\n */\nexport class MultiSortPlugin extends BaseGridPlugin<MultiSortConfig> {\n /**\n * Plugin manifest declaring query types this plugin responds to.\n * @internal\n */\n static override readonly manifest: PluginManifest = {\n queries: [\n { type: 'sort:get-model', description: 'Returns the current multi-sort model as SortModel[]' },\n { type: 'sort:set-model', description: 'Sets the multi-sort model from context (SortModel[])' },\n ],\n };\n\n /** @internal */\n readonly name = 'multiSort';\n /** @internal */\n override readonly styles = styles;\n\n /** @internal */\n protected override get defaultConfig(): Partial<MultiSortConfig> {\n return {\n maxSortColumns: 3,\n showSortIndex: true,\n };\n }\n\n // #region Internal State\n private sortModel: SortModel[] = [];\n /** Cached sort result — returned as-is while a row edit is active to prevent\n * the edited row from jumping to a new sorted position mid-edit. Row data\n * mutations are still visible because the array holds shared object refs. */\n private cachedSortResult: unknown[] | null = null;\n /** Guards deferred sort-change broadcast during applyColumnState batch. */\n #pendingStateBroadcast = false;\n /** Snapshot of sortModel taken at the start of an applyColumnState batch,\n * used by the deferred broadcast to skip when the model didn't change. */\n #preBatchSortModel: SortModel[] = [];\n\n /** Typed internal grid accessor. */\n get #internalGrid(): GridHost {\n return this.grid;\n }\n\n /**\n * Clear the core `_sortState` so that only this plugin's `processRows`\n * sorting applies. `ConfigManager.applyState()` always sets the core sort\n * state when restoring from storage, even when a plugin handles sorting.\n * Without this, the stale core state leaks into `collectState()` and\n * `reapplyCoreSort()` after the plugin clears its own model.\n */\n private clearCoreSortState(): void {\n this.#internalGrid._sortState = null;\n }\n\n /**\n * Remove sorts on fields that are owned by the grouping plugin.\n * GroupingRowsPlugin handles group header ordering independently, so\n * multi-sort should only sort by non-grouped data columns.\n */\n #filterGroupedFields(model: SortModel[]): SortModel[] {\n const results = this.grid?.query?.('grouping:get-grouped-fields', null);\n if (!Array.isArray(results) || results.length === 0) return model;\n\n const groupedFields = results[0] as string[];\n if (!Array.isArray(groupedFields) || groupedFields.length === 0) return model;\n\n const groupedSet = new Set(groupedFields);\n const filtered = model.filter((s) => !groupedSet.has(s.field));\n return filtered.length === model.length ? model : filtered;\n }\n // #endregion\n\n // #region Lifecycle\n\n /** @internal */\n override detach(): void {\n this.sortModel = [];\n this.cachedSortResult = null;\n }\n // #endregion\n\n // #region Query System\n\n /** @internal */\n override handleQuery(query: PluginQuery): unknown {\n switch (query.type) {\n case 'sort:get-model':\n return [...this.sortModel];\n case 'sort:set-model': {\n const model = query.context;\n if (!Array.isArray(model)) return false;\n this.sortModel = [...model] as SortModel[];\n this.clearCoreSortState();\n this.broadcast('sort-change', { sortModel: [...this.sortModel] });\n this.requestRender();\n return true;\n }\n default:\n return undefined;\n }\n }\n\n // #endregion\n\n // #region Hooks\n\n /** @internal */\n override processRows(rows: readonly unknown[]): unknown[] {\n // Nothing to sort: hand the input on untouched. A copy here would give every\n // render an O(n) slice and make ServerSide's `_rows !== managedNodes` check\n // treat each block load as a derived row model (#521).\n if (this.sortModel.length === 0) {\n this.cachedSortResult = null;\n return rows as unknown[];\n }\n\n // A ServerSide data source in `sortMode: 'server'` already sorted the rows\n // (via `getRows({ sortModel })`) with its own collation / computed keys, and\n // its loading placeholders sit at their block positions — keep that order (#521).\n if (this.grid?.query?.<'server' | 'local'>('datasource:sort-mode', null)?.[0] === 'server') {\n this.cachedSortResult = null;\n return rows as unknown[];\n }\n\n // Freeze sort order while a row is actively being edited (row mode only).\n // Re-sorting mid-edit would move the edited row to a new index while the\n // editors remain at the old position, causing data/UI mismatch.\n // In grid mode (_isGridEditMode) sorting is safe — afterCellRender\n // re-injects editors into the re-sorted cells.\n // We return the cached previous sort result (same object references, so\n // in-place value mutations are already visible) instead of unsorted input.\n const grid = this.#internalGrid;\n if (!grid._isGridEditMode && typeof grid._activeEditRows === 'number' && grid._activeEditRows !== -1) {\n if (this.cachedSortResult && this.cachedSortResult.length === rows.length) {\n return [...this.cachedSortResult];\n }\n }\n\n // Sort a copy: with no earlier copying stage the input IS the consumer's\n // `rows` array (`grid.sourceRows`) — sorting it in place reordered their data\n // and left nothing to restore when the sort is cleared. The copy is O(n)\n // next to the O(n log n) sort. A pre-resolved comparator chain avoids\n // column lookup on every pair comparison.\n // Exclude fields owned by the grouping plugin — group header order is handled\n // by GroupingRowsPlugin, so multi-sort should only affect within-group data order.\n const effectiveModel = this.#filterGroupedFields(this.sortModel);\n\n const mutableRows = [...rows];\n if (effectiveModel.length > 0) {\n sortRowsInPlace(mutableRows, effectiveModel, this.columns);\n }\n this.cachedSortResult = mutableRows;\n return mutableRows;\n }\n\n /** @internal */\n override onHeaderClick(event: HeaderClickEvent): boolean {\n const column = this.columns.find((c) => c.field === event.field);\n if (!column?.sortable) return false;\n\n const shiftKey = event.originalEvent.shiftKey;\n const maxColumns = this.config.maxSortColumns ?? 3;\n\n this.sortModel = toggleSort(this.sortModel, event.field, shiftKey, maxColumns);\n this.clearCoreSortState();\n\n this.broadcast('sort-change', { sortModel: [...this.sortModel] });\n this.requestRender();\n this.grid?.requestStateChange?.();\n\n // Announce for screen readers\n if (this.sortModel.length > 0) {\n const labels = this.sortModel.map((s) => {\n const col = this.columns.find((c) => c.field === s.field);\n return `${col?.header ?? s.field} ${s.direction === 'asc' ? 'ascending' : 'descending'}`;\n });\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortApplied', labels.join(', then '), ''));\n } else {\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortCleared'));\n }\n\n return true;\n }\n\n /** @internal */\n override afterRender(): void {\n const gridEl = this.gridElement;\n if (!gridEl) return;\n\n const showIndex = this.config.showSortIndex !== false;\n\n const headerCells = gridEl.querySelectorAll('.header-row .cell[data-field]');\n headerCells.forEach((cell) => {\n const field = cell.getAttribute('data-field');\n if (!field) return;\n\n const sortIndex = getSortIndex(this.sortModel, field);\n const sortDir = getSortDirection(this.sortModel, field);\n\n // Remove existing sort index badge (always clean up)\n cell.querySelector('.sort-index')?.remove();\n\n if (sortDir) {\n const indicator = this.updateSortIndicator(cell, sortDir);\n\n // Add sort index badge if multiple columns sorted and showSortIndex is enabled\n if (showIndex && this.sortModel.length > 1 && sortIndex !== undefined) {\n const badge = document.createElement('span');\n badge.className = 'sort-index';\n badge.textContent = String(sortIndex);\n if (indicator.nextSibling) {\n cell.insertBefore(badge, indicator.nextSibling);\n } else {\n cell.appendChild(badge);\n }\n }\n } else if (cell.classList.contains('sortable')) {\n this.updateSortIndicator(cell, null);\n }\n });\n }\n // #endregion\n\n // #region Public API\n\n /**\n * Get the current sort model.\n * @returns Copy of the current sort model\n */\n getSortModel(): SortModel[] {\n return [...this.sortModel];\n }\n\n /**\n * Set the sort model programmatically.\n * @param model - New sort model to apply\n */\n setSortModel(model: SortModel[]): void {\n this.sortModel = [...model];\n this.clearCoreSortState();\n this.broadcast('sort-change', { sortModel: [...model] });\n this.requestRender();\n this.grid?.requestStateChange?.();\n if (model.length > 0) {\n const labels = model.map((s) => {\n const col = this.columns.find((c) => c.field === s.field);\n return `${col?.header ?? s.field} ${s.direction === 'asc' ? 'ascending' : 'descending'}`;\n });\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortApplied', labels.join(', then '), ''));\n }\n }\n\n /**\n * Clear all sorting.\n */\n clearSort(): void {\n this.sortModel = [];\n this.clearCoreSortState();\n this.broadcast('sort-change', { sortModel: [] });\n this.requestRender();\n this.grid?.requestStateChange?.();\n announce(this.gridElement!, getA11yMessage(this.gridElement!, 'sortCleared'));\n }\n\n /**\n * Get the sort index (1-based) for a specific field.\n * @param field - Field to check\n * @returns 1-based index or undefined if not sorted\n */\n getSortIndex(field: string): number | undefined {\n return getSortIndex(this.sortModel, field);\n }\n\n /**\n * Get the sort direction for a specific field.\n * @param field - Field to check\n * @returns Sort direction or undefined if not sorted\n */\n getSortDirection(field: string): 'asc' | 'desc' | undefined {\n return getSortDirection(this.sortModel, field);\n }\n // #endregion\n\n // #region Column State Hooks\n\n /**\n * Return sort state for a column if it's in the sort model.\n * @internal\n */\n override getColumnState(field: string): Partial<ColumnState> | undefined {\n const index = this.sortModel.findIndex((s) => s.field === field);\n if (index === -1) return undefined;\n\n const sortEntry = this.sortModel[index];\n return {\n sort: {\n direction: sortEntry.direction,\n priority: index,\n },\n };\n }\n\n /**\n * Apply sort state from column state.\n * Rebuilds the sort model from all column states.\n * @internal\n */\n override applyColumnState(field: string, state: ColumnState): void {\n // Snapshot model before mutation so the microtask can skip a no-op\n // broadcast/render when nothing actually changed.\n if (!this.#pendingStateBroadcast) {\n this.#preBatchSortModel = [...this.sortModel];\n }\n\n if (!state.sort) {\n // Remove this field from sortModel if it exists\n this.sortModel = this.sortModel.filter((s) => s.field !== field);\n } else {\n // Find existing entry or add new one\n const existingIndex = this.sortModel.findIndex((s) => s.field === field);\n const newEntry: SortModel = {\n field,\n direction: state.sort.direction,\n };\n\n if (existingIndex !== -1) {\n // Update existing entry\n this.sortModel[existingIndex] = newEntry;\n } else {\n // Add at the correct priority position\n this.sortModel.splice(state.sort.priority, 0, newEntry);\n }\n }\n\n // Clear core sort state — this plugin exclusively handles sorting via\n // processRows. The core _sortState is set by ConfigManager.applyState()\n // before plugins run; null it so reapplyCoreSort() is a no-op.\n this.clearCoreSortState();\n\n // Broadcast sort-change + force a row re-render after state restoration.\n // Both are deferred to a microtask so per-column calls coalesce into one\n // event and one render. The render handles the \"sort cleared\" case that\n // core's width-only fast path cannot detect (core only inspects the\n // *incoming* state, not what the plugin's model became). Skip entirely\n // when the model is unchanged so unrelated state restores stay cheap.\n if (!this.#pendingStateBroadcast) {\n this.#pendingStateBroadcast = true;\n queueMicrotask(() => {\n this.#pendingStateBroadcast = false;\n const prev = this.#preBatchSortModel;\n this.#preBatchSortModel = [];\n if (sortModelEquals(prev, this.sortModel)) return;\n this.broadcast('sort-change', { sortModel: [...this.sortModel] });\n this.requestRender();\n });\n }\n }\n // #endregion\n}\n\n/** Shallow equality for two sort models (order-sensitive). */\nfunction sortModelEquals(a: SortModel[], b: SortModel[]): boolean {\n if (a.length !== b.length) return false;\n for (let i = 0; i < a.length; i++) {\n if (a[i].field !== b[i].field || a[i].direction !== b[i].direction) return false;\n }\n return true;\n}\n"],"mappings":"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"}