@ornery/ui-grid-vanilla 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md ADDED
@@ -0,0 +1,70 @@
1
+ # @ornery/ui-grid-vanilla — Agent Instructions
2
+
3
+ The rendering engine. A framework-free custom element (`<ui-grid-element>`) that owns the Shadow DOM, CSS Grid layout, event handling, and the `VanillaGridController` state machine. Angular and React wrappers mount this element — they never render cells themselves.
4
+
5
+ ## Build & Test
6
+
7
+ ```bash
8
+ npm run build --prefix projects/ui-grid-vanilla # tsup → dist/
9
+ npm test --prefix projects/ui-grid-vanilla # vitest (138 tests)
10
+ ```
11
+
12
+ Depends on `@ornery/ui-grid-core` — build core first.
13
+
14
+ ## Key Files
15
+
16
+ | File | Responsibility |
17
+ |------|---------------|
18
+ | `grid-controller.ts` | `VanillaGridController` — all mutable grid state, snapshot emission, column width calculation |
19
+ | `ui-grid-standalone.element.ts` | Custom element class: lifecycle, render orchestration, scroll handling, ResizeObserver |
20
+ | `render.ts` | HTML string builders for header/filter/body cells |
21
+ | `patch.ts` | DOM-diffing fast path: patches existing shadow DOM without full re-render |
22
+ | `events.ts` | All event listeners: click, mousedown (resize drag), dblclick (auto-fit), keydown, scroll, drag-and-drop |
23
+ | `focus.ts` | CellNav focus management, `measureAutoColumnWidth` |
24
+ | `framework-slots.ts` | `FrameworkSlotBridge` — stages slot add/remove deltas, `flush()` dispatches `cellSlotsChanged` |
25
+ | `attribute-bridge.ts` | Parses HTML attributes into `GridOptions` |
26
+ | `templates/index.ts` | Markup fragment helpers (filterRow, bodyVirtual, bodyStatic) |
27
+ | `ui-grid-shell.template.ts` | Full shell template (grid-frame → grid-table → header-strip + filter-strip + body-viewport) |
28
+
29
+ ## Architecture
30
+
31
+ ### Render Paths
32
+
33
+ 1. **`renderFull`** — Full innerHTML swap of the shadow DOM shell. Used on structure changes (column add/remove, virtualization toggle).
34
+ 2. **`renderPatch`** — In-place DOM mutation. Header grid-template-columns, filter cells, body rows patched individually. Preserves focus and scroll.
35
+ 3. **`renderVirtualBody`** — Scroll-triggered fast path. Only replaces `.grid-virtual-body` innerHTML + calls `frameworkSlots.flush()`.
36
+ 4. **`patchCells`** — Data-only update. Iterates existing body cells, updates `.cell-content` innerHTML.
37
+
38
+ ### Column Width (drawnWidth)
39
+
40
+ Column widths are resolved to **fixed pixel values** by `buildGridTemplateColumns()`, mirroring the old ui-grid's `updateColumnWidths`/`drawnWidth` approach:
41
+
42
+ - Fixed-px columns: used as-is
43
+ - Percentage columns: resolved against viewport width
44
+ - Flex columns (default `minmax(11rem, 1fr)`): remaining space divided equally, minimum 176px
45
+ - Selection row-header: fixed at configured width, not resizable
46
+
47
+ The element feeds its measured width to the controller via `setViewportWidth()`. The ResizeObserver keeps it current.
48
+
49
+ ### State Management
50
+
51
+ `setOptions()` must NOT clobber interactive state. The controller tracks `lastOptions*` values and only applies declarative state when the consumer's value structurally changed. Interactive state that survives `setOptions`: sort, filters, expanded rows, selection, pagination, cell navigation, column widths, column order.
52
+
53
+ ### Framework Slot System
54
+
55
+ 1. Wrapper calls `setFrameworkRenderedSlots({ cells: ['colA', 'colB'] })`
56
+ 2. Render emits `<slot name="cell-colA-rowId">` placeholders
57
+ 3. `flush()` dispatches `cellSlotsChanged` with `{ added, removed }` deltas
58
+ 4. Wrapper listens, creates framework views, appends to light DOM with matching `slot` attribute
59
+
60
+ ## Per-Column Flags
61
+
62
+ - `enableColumnResizing: false` — no resize handle rendered, column width locked
63
+ - `enableColumnMoving: false` — not draggable, not a valid drop target
64
+
65
+ ## Do NOT
66
+
67
+ - Skip `frameworkSlots.flush()` after rendering DOM that contains slot placeholders
68
+ - Call `setFrameworkRenderedSlots` on every options update — only on structural column changes
69
+ - Use `innerHTML` on `.grid-body-viewport` — it destroys scroll position; only patch inside `.body-grid`
70
+ - Forget to sync horizontal scroll after a full render (`syncHeaderHorizontalScroll`)
package/dist/index.d.mts CHANGED
@@ -245,6 +245,10 @@ declare class VanillaGridController {
245
245
  private activeFilters;
246
246
  private sortState;
247
247
  private groupByColumns;
248
+ private lastOptionsGroupBy;
249
+ private lastOptionsPinnedState;
250
+ private lastOptionsInfiniteScrollUp;
251
+ private lastOptionsInfiniteScrollDown;
248
252
  private collapsedGroups;
249
253
  private hiddenRowReasons;
250
254
  private expandedRows;
@@ -252,6 +256,7 @@ declare class VanillaGridController {
252
256
  private pinnedColumns;
253
257
  private columnOrder;
254
258
  private columnWidthOverrides;
259
+ private viewportWidth;
255
260
  private currentPage;
256
261
  private pageSize;
257
262
  private editingCell;
@@ -387,6 +392,8 @@ declare class VanillaGridController {
387
392
  setInfiniteScrollResetHandler(handler: (() => void) | null): void;
388
393
  getInfiniteScrollState(): Readonly<GridInfiniteScrollState>;
389
394
  canResizeColumns(): boolean;
395
+ setViewportWidth(width: number): void;
396
+ getViewportWidth(): number;
390
397
  setColumnWidthOverride(columnName: string, widthPx: number): void;
391
398
  /** Returns the grid-template-columns string as if `columnName` had the given width,
392
399
  * without triggering a full refresh. Used for smooth drag-resize DOM updates. */
package/dist/index.d.ts CHANGED
@@ -245,6 +245,10 @@ declare class VanillaGridController {
245
245
  private activeFilters;
246
246
  private sortState;
247
247
  private groupByColumns;
248
+ private lastOptionsGroupBy;
249
+ private lastOptionsPinnedState;
250
+ private lastOptionsInfiniteScrollUp;
251
+ private lastOptionsInfiniteScrollDown;
248
252
  private collapsedGroups;
249
253
  private hiddenRowReasons;
250
254
  private expandedRows;
@@ -252,6 +256,7 @@ declare class VanillaGridController {
252
256
  private pinnedColumns;
253
257
  private columnOrder;
254
258
  private columnWidthOverrides;
259
+ private viewportWidth;
255
260
  private currentPage;
256
261
  private pageSize;
257
262
  private editingCell;
@@ -387,6 +392,8 @@ declare class VanillaGridController {
387
392
  setInfiniteScrollResetHandler(handler: (() => void) | null): void;
388
393
  getInfiniteScrollState(): Readonly<GridInfiniteScrollState>;
389
394
  canResizeColumns(): boolean;
395
+ setViewportWidth(width: number): void;
396
+ getViewportWidth(): number;
390
397
  setColumnWidthOverride(columnName: string, widthPx: number): void;
391
398
  /** Returns the grid-template-columns string as if `columnName` had the given width,
392
399
  * without triggering a full refresh. Used for smooth drag-resize DOM updates. */
package/dist/index.js CHANGED
@@ -649,7 +649,7 @@ function renderHeaderCell(el, column, sortEnabled, groupingEnabled, pinningEnabl
649
649
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : "";
650
650
  const sortIconKey = sortDirection === import_ui_grid_core2.SORT_DIRECTIONS.asc ? "sortAsc" : sortDirection === import_ui_grid_core2.SORT_DIRECTIONS.desc ? "sortDesc" : "sortNone";
651
651
  const pinLabel = isPinned ? el.snapshot?.labels.unpin ?? "Unpin" : el.snapshot?.labels.pinColumn ?? "Pin";
652
- const canResize = controller.canResizeColumns();
652
+ const canResize = controller.canResizeColumns() && column.enableColumnResizing !== false;
653
653
  const headerValue = escapeHtml((0, import_ui_grid_core2.formatGridHeaderDisplayValue)((0, import_ui_grid_core2.buildGridHeaderContext)(column)));
654
654
  const resizerHtml = canResize ? resizerMarkup(escapeHtml(column.name), escapeHtml(headerValue)) : "";
655
655
  const isPinnedLeftLast = controller.isPinnedLeftLast(column);
@@ -657,7 +657,7 @@ function renderHeaderCell(el, column, sortEnabled, groupingEnabled, pinningEnabl
657
657
  const isPinMenuOpen = el.openPinMenuColumn === column.name;
658
658
  const isDragTarget = el.dropTargetColumnName === column.name;
659
659
  const isDragging = el.draggedColumnName === column.name;
660
- const isDraggable = (0, import_ui_grid_core2.canGridMoveColumns)(options);
660
+ const isDraggable = (0, import_ui_grid_core2.canGridMoveColumns)(options) && column.enableColumnMoving !== false;
661
661
  const className = headerCellClass(
662
662
  sortDirection !== import_ui_grid_core2.SORT_DIRECTIONS.none,
663
663
  isPinned,
@@ -1504,18 +1504,35 @@ function measureAutoColumnWidth(el, columnName) {
1504
1504
  const root = el.shadowRoot;
1505
1505
  if (root == null) return 176;
1506
1506
  const escaped = cssEscape(columnName);
1507
- const selectors = [
1508
- `.header-cell[data-column="${escaped}"]`,
1509
- `.filter-cell[data-column="${escaped}"]`,
1510
- `.body-cell[data-column="${escaped}"] .cell-shell`
1511
- ];
1507
+ const probe = document.createElement("div");
1508
+ probe.style.cssText = "position:absolute;top:-9999px;left:-9999px;width:max-content;visibility:hidden;";
1509
+ root.appendChild(probe);
1512
1510
  let maxWidth = 0;
1513
- for (const selector of selectors) {
1514
- for (const target of root.querySelectorAll(selector)) {
1515
- maxWidth = Math.max(maxWidth, target.scrollWidth);
1516
- }
1517
- }
1518
- return maxWidth + 12;
1511
+ for (const header of root.querySelectorAll(`.header-cell[data-column="${escaped}"]`)) {
1512
+ const clone = header.cloneNode(true);
1513
+ clone.style.cssText = "display:grid;grid-template-columns:auto auto;gap:inherit;padding:inherit;width:max-content;";
1514
+ probe.appendChild(clone);
1515
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
1516
+ probe.removeChild(clone);
1517
+ }
1518
+ for (const filter of root.querySelectorAll(`ui-grid-filter-cell[data-column="${escaped}"], .filter-cell[data-column="${escaped}"]`)) {
1519
+ const clone = filter.cloneNode(true);
1520
+ clone.style.cssText = "display:block;width:max-content;";
1521
+ probe.appendChild(clone);
1522
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
1523
+ probe.removeChild(clone);
1524
+ }
1525
+ for (const cell of root.querySelectorAll(`.body-cell[data-column="${escaped}"]`)) {
1526
+ const shell = cell.querySelector(".cell-shell");
1527
+ if (!shell) continue;
1528
+ const clone = shell.cloneNode(true);
1529
+ clone.style.cssText = "display:flex;width:max-content;";
1530
+ probe.appendChild(clone);
1531
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
1532
+ probe.removeChild(clone);
1533
+ }
1534
+ root.removeChild(probe);
1535
+ return Math.max(88, maxWidth + 12);
1519
1536
  }
1520
1537
 
1521
1538
  // src/events.ts
@@ -1987,6 +2004,11 @@ function bindEvents(el) {
1987
2004
  event.preventDefault();
1988
2005
  return;
1989
2006
  }
2007
+ const colDef = el.snapshot.visibleColumns.find((c) => c.name === columnName);
2008
+ if (colDef?.enableColumnMoving === false) {
2009
+ event.preventDefault();
2010
+ return;
2011
+ }
1990
2012
  el.draggedColumnName = columnName;
1991
2013
  el.dropTargetColumnName = null;
1992
2014
  dragEvent.dataTransfer.effectAllowed = "move";
@@ -2000,6 +2022,8 @@ function bindEvents(el) {
2000
2022
  if (!headerCell || !el.draggedColumnName || !dragEvent.dataTransfer) return;
2001
2023
  const columnName = headerCell.dataset["column"];
2002
2024
  if (!columnName || columnName === el.draggedColumnName) return;
2025
+ const colDef = el.snapshot?.visibleColumns.find((c) => c.name === columnName);
2026
+ if (colDef?.enableColumnMoving === false) return;
2003
2027
  event.preventDefault();
2004
2028
  dragEvent.dataTransfer.dropEffect = "move";
2005
2029
  if (el.dropTargetColumnName !== columnName) {
@@ -2140,8 +2164,72 @@ var import_ui_grid_core5 = require("@ornery/ui-grid-core");
2140
2164
  function orderVisibleColumns(columns, order) {
2141
2165
  return [...columns].filter((column) => column.visible !== false).sort((left, right) => order.indexOf(left.name) - order.indexOf(right.name));
2142
2166
  }
2143
- function buildGridTemplateColumns(columns) {
2144
- return columns.map((column) => (0, import_ui_grid_core5.gridColumnWidth)(column)).join(" ");
2167
+ var DEFAULT_COL_MIN_WIDTH = 176;
2168
+ function buildGridTemplateColumns(columns, viewportWidth) {
2169
+ if (!viewportWidth || viewportWidth <= 0) {
2170
+ return columns.map((column) => (0, import_ui_grid_core5.gridColumnWidth)(column)).join(" ");
2171
+ }
2172
+ const availableWidth = viewportWidth;
2173
+ const resolved = new Array(columns.length);
2174
+ const flexIndices = [];
2175
+ let usedWidth = 0;
2176
+ for (let i = 0; i < columns.length; i++) {
2177
+ const raw = columns[i].width;
2178
+ const minWidth = columns[i].minWidth;
2179
+ const maxWidth = columns[i].maxWidth ?? Infinity;
2180
+ if (raw && raw.endsWith("px")) {
2181
+ let px = parseInt(raw, 10);
2182
+ if (minWidth != null) px = Math.max(minWidth, px);
2183
+ px = Math.min(maxWidth, px);
2184
+ resolved[i] = px;
2185
+ usedWidth += px;
2186
+ } else if (raw && raw.endsWith("%")) {
2187
+ const pct = parseInt(raw.replace(/%/g, ""), 10);
2188
+ let w = Math.round(pct / 100 * availableWidth);
2189
+ if (minWidth != null) w = Math.max(minWidth, w);
2190
+ w = Math.min(maxWidth, w);
2191
+ resolved[i] = w;
2192
+ usedWidth += w;
2193
+ } else {
2194
+ flexIndices.push(i);
2195
+ resolved[i] = 0;
2196
+ }
2197
+ }
2198
+ if (flexIndices.length > 0) {
2199
+ const remaining = Math.max(0, availableWidth - usedWidth);
2200
+ const perFlex = Math.floor(remaining / flexIndices.length);
2201
+ for (const idx of flexIndices) {
2202
+ const minWidth = columns[idx].minWidth ?? DEFAULT_COL_MIN_WIDTH;
2203
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
2204
+ const w = Math.max(minWidth, Math.min(maxWidth, perFlex));
2205
+ resolved[idx] = w;
2206
+ usedWidth += w;
2207
+ }
2208
+ let leftover = availableWidth - usedWidth;
2209
+ let changed = true;
2210
+ while (leftover > 0 && changed) {
2211
+ changed = false;
2212
+ for (const idx of flexIndices) {
2213
+ if (leftover <= 0) break;
2214
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
2215
+ if (resolved[idx] < maxWidth) {
2216
+ resolved[idx]++;
2217
+ leftover--;
2218
+ changed = true;
2219
+ }
2220
+ }
2221
+ }
2222
+ }
2223
+ return resolved.map((w) => `${w}px`).join(" ");
2224
+ }
2225
+ function pinnedStatesEqual(a, b) {
2226
+ const keysA = Object.keys(a);
2227
+ const keysB = Object.keys(b);
2228
+ if (keysA.length !== keysB.length) return false;
2229
+ for (const key of keysA) {
2230
+ if (a[key] !== b[key]) return false;
2231
+ }
2232
+ return true;
2145
2233
  }
2146
2234
  var VanillaGridController = class {
2147
2235
  options;
@@ -2151,6 +2239,10 @@ var VanillaGridController = class {
2151
2239
  direction: import_ui_grid_core5.SORT_DIRECTIONS.none
2152
2240
  };
2153
2241
  groupByColumns = [];
2242
+ lastOptionsGroupBy = void 0;
2243
+ lastOptionsPinnedState = void 0;
2244
+ lastOptionsInfiniteScrollUp = void 0;
2245
+ lastOptionsInfiniteScrollDown = void 0;
2154
2246
  collapsedGroups = {};
2155
2247
  hiddenRowReasons = {};
2156
2248
  expandedRows = {};
@@ -2158,6 +2250,7 @@ var VanillaGridController = class {
2158
2250
  pinnedColumns = {};
2159
2251
  columnOrder = [];
2160
2252
  columnWidthOverrides = {};
2253
+ viewportWidth = 0;
2161
2254
  currentPage = 1;
2162
2255
  pageSize = 0;
2163
2256
  editingCell = null;
@@ -2194,7 +2287,12 @@ var VanillaGridController = class {
2194
2287
  this.validatorRegistry = (0, import_ui_grid_core5.createGridValidatorRegistry)(this.labels);
2195
2288
  this.columnOrder = options.columnDefs.map((column) => column.name);
2196
2289
  this.groupByColumns = options.grouping?.groupBy ? [...options.grouping.groupBy] : [];
2290
+ this.lastOptionsGroupBy = options.grouping?.groupBy ? [...options.grouping.groupBy] : void 0;
2197
2291
  this.pinnedColumns = (0, import_ui_grid_core5.buildInitialPinnedState)(options.columnDefs);
2292
+ const declaresPin = options.columnDefs.some((col) => col.pinnedLeft === true || col.pinnedRight === true);
2293
+ this.lastOptionsPinnedState = declaresPin ? { ...this.pinnedColumns } : void 0;
2294
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
2295
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
2198
2296
  this.infiniteScrollState = (0, import_ui_grid_core5.resetInfiniteScrollState)(
2199
2297
  options.infiniteScrollUp === true,
2200
2298
  options.infiniteScrollDown !== false
@@ -2411,20 +2509,41 @@ var VanillaGridController = class {
2411
2509
  }
2412
2510
  }
2413
2511
  if (options.grouping?.groupBy !== void 0) {
2414
- this.groupByColumns = [...options.grouping.groupBy];
2512
+ const incoming = options.grouping.groupBy;
2513
+ const prev = this.lastOptionsGroupBy;
2514
+ const changed = !prev || prev.length !== incoming.length || prev.some((col, i) => col !== incoming[i]);
2515
+ if (changed) {
2516
+ this.groupByColumns = [...incoming];
2517
+ this.lastOptionsGroupBy = [...incoming];
2518
+ }
2519
+ } else if (this.lastOptionsGroupBy !== void 0) {
2520
+ this.lastOptionsGroupBy = void 0;
2415
2521
  }
2416
2522
  const declaresPin = options.columnDefs.some(
2417
2523
  (col) => col.pinnedLeft === true || col.pinnedRight === true
2418
2524
  );
2419
2525
  if (declaresPin) {
2420
- this.pinnedColumns = (0, import_ui_grid_core5.buildInitialPinnedState)(options.columnDefs);
2526
+ const incoming = (0, import_ui_grid_core5.buildInitialPinnedState)(options.columnDefs);
2527
+ const prev = this.lastOptionsPinnedState;
2528
+ if (!prev || !pinnedStatesEqual(prev, incoming)) {
2529
+ this.pinnedColumns = incoming;
2530
+ this.lastOptionsPinnedState = { ...incoming };
2531
+ }
2532
+ } else if (this.lastOptionsPinnedState !== void 0) {
2533
+ this.lastOptionsPinnedState = void 0;
2421
2534
  }
2422
2535
  if (options.infiniteScrollUp !== void 0 || options.infiniteScrollDown !== void 0) {
2423
- this.infiniteScrollState = (0, import_ui_grid_core5.setInfiniteScrollDirectionsState)(
2424
- this.infiniteScrollState,
2425
- options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
2426
- options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown
2427
- );
2536
+ const upChanged = options.infiniteScrollUp !== void 0 && options.infiniteScrollUp !== this.lastOptionsInfiniteScrollUp;
2537
+ const downChanged = options.infiniteScrollDown !== void 0 && options.infiniteScrollDown !== this.lastOptionsInfiniteScrollDown;
2538
+ if (upChanged || downChanged) {
2539
+ this.infiniteScrollState = (0, import_ui_grid_core5.setInfiniteScrollDirectionsState)(
2540
+ this.infiniteScrollState,
2541
+ options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
2542
+ options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown
2543
+ );
2544
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
2545
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
2546
+ }
2428
2547
  }
2429
2548
  this.apiRegistered = false;
2430
2549
  this.refresh();
@@ -2466,7 +2585,7 @@ var VanillaGridController = class {
2466
2585
  labels: this.labels,
2467
2586
  rowSize: this.getRowSize(),
2468
2587
  visibleColumns: this.visibleColumns,
2469
- gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns),
2588
+ gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns, this.viewportWidth),
2470
2589
  pipeline: this.pipeline,
2471
2590
  activeFilters: this.activeFilters,
2472
2591
  sortState: this.sortState,
@@ -3117,6 +3236,12 @@ var VanillaGridController = class {
3117
3236
  canResizeColumns() {
3118
3237
  return this.options.enableColumnResizing !== false;
3119
3238
  }
3239
+ setViewportWidth(width) {
3240
+ this.viewportWidth = Math.round(width);
3241
+ }
3242
+ getViewportWidth() {
3243
+ return this.viewportWidth;
3244
+ }
3120
3245
  setColumnWidthOverride(columnName, widthPx) {
3121
3246
  const nextWidth = `${Math.max(88, Math.round(widthPx))}px`;
3122
3247
  this.columnWidthOverrides = { ...this.columnWidthOverrides, [columnName]: nextWidth };
@@ -3129,7 +3254,8 @@ var VanillaGridController = class {
3129
3254
  return buildGridTemplateColumns(
3130
3255
  this.visibleColumns.map(
3131
3256
  (c) => c.name === columnName ? { ...c, width: widthStr } : c
3132
- )
3257
+ ),
3258
+ this.viewportWidth
3133
3259
  );
3134
3260
  }
3135
3261
  isGroupingEnabled() {
@@ -3366,11 +3492,15 @@ var VanillaGridController = class {
3366
3492
  name: "selectionRowHeaderCol",
3367
3493
  displayName: "",
3368
3494
  width: `${resolvedSelection.selectionRowHeaderWidth}px`,
3495
+ minWidth: resolvedSelection.selectionRowHeaderWidth,
3496
+ maxWidth: resolvedSelection.selectionRowHeaderWidth,
3369
3497
  enableSorting: false,
3370
3498
  enableFiltering: false,
3371
3499
  enableGrouping: false,
3372
3500
  enableCellEdit: false,
3373
3501
  enablePinning: false,
3502
+ enableColumnResizing: false,
3503
+ enableColumnMoving: false,
3374
3504
  align: "center"
3375
3505
  };
3376
3506
  if (!this.visibleColumns.some((c) => c.name === "selectionRowHeaderCol")) {
@@ -5047,10 +5177,16 @@ var UiGridStandaloneElement = class extends HTMLElement {
5047
5177
  /** @internal */
5048
5178
  ensureController(options) {
5049
5179
  if (this.controller) {
5180
+ if (this.clientWidth > 0) {
5181
+ this.controller.setViewportWidth(this.clientWidth);
5182
+ }
5050
5183
  this.controller.setOptions(options);
5051
5184
  return;
5052
5185
  }
5053
5186
  this.controller = createVanillaGridController(options);
5187
+ if (this.clientWidth > 0) {
5188
+ this.controller.setViewportWidth(this.clientWidth);
5189
+ }
5054
5190
  this.controller.setCellNavScrollHandler((rowId, columnName) => {
5055
5191
  if (!rowId || !columnName) return;
5056
5192
  this.scrollFocusedRowIntoView(rowId);
@@ -5184,6 +5320,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
5184
5320
  virtualBody.innerHTML = itemsToRender.map((item, index) => this.renderDisplayItem(item, startIndex + index)).join("");
5185
5321
  this.lastItemsFingerprint = this.fingerprintItems(itemsToRender);
5186
5322
  this.lastVirtualOffset = virtualOffset;
5323
+ this.frameworkSlots.flush();
5187
5324
  }
5188
5325
  /** @internal */
5189
5326
  render() {
@@ -5283,10 +5420,12 @@ var UiGridStandaloneElement = class extends HTMLElement {
5283
5420
  if (this.autoResizeObserver) return;
5284
5421
  if (typeof ResizeObserver === "undefined") return;
5285
5422
  this.autoResizeObserver = new ResizeObserver((entries) => {
5286
- const options = this.snapshot?.options;
5287
- if (options?.enableAutoResize === false) return;
5288
5423
  const entry = entries[0];
5289
5424
  if (!entry) return;
5425
+ const newWidth = entry.contentRect.width;
5426
+ if (this.controller && newWidth > 0) {
5427
+ this.controller.setViewportWidth(newWidth);
5428
+ }
5290
5429
  if (this.autoResizeDebounceHandle !== null) {
5291
5430
  cancelAnimationFrame(this.autoResizeDebounceHandle);
5292
5431
  }
package/dist/index.mjs CHANGED
@@ -630,7 +630,7 @@ function renderHeaderCell(el, column, sortEnabled, groupingEnabled, pinningEnabl
630
630
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : "";
631
631
  const sortIconKey = sortDirection === SORT_DIRECTIONS.asc ? "sortAsc" : sortDirection === SORT_DIRECTIONS.desc ? "sortDesc" : "sortNone";
632
632
  const pinLabel = isPinned ? el.snapshot?.labels.unpin ?? "Unpin" : el.snapshot?.labels.pinColumn ?? "Pin";
633
- const canResize = controller.canResizeColumns();
633
+ const canResize = controller.canResizeColumns() && column.enableColumnResizing !== false;
634
634
  const headerValue = escapeHtml(formatGridHeaderDisplayValue(buildGridHeaderContext2(column)));
635
635
  const resizerHtml = canResize ? resizerMarkup(escapeHtml(column.name), escapeHtml(headerValue)) : "";
636
636
  const isPinnedLeftLast = controller.isPinnedLeftLast(column);
@@ -638,7 +638,7 @@ function renderHeaderCell(el, column, sortEnabled, groupingEnabled, pinningEnabl
638
638
  const isPinMenuOpen = el.openPinMenuColumn === column.name;
639
639
  const isDragTarget = el.dropTargetColumnName === column.name;
640
640
  const isDragging = el.draggedColumnName === column.name;
641
- const isDraggable = canGridMoveColumns(options);
641
+ const isDraggable = canGridMoveColumns(options) && column.enableColumnMoving !== false;
642
642
  const className = headerCellClass(
643
643
  sortDirection !== SORT_DIRECTIONS.none,
644
644
  isPinned,
@@ -1487,18 +1487,35 @@ function measureAutoColumnWidth(el, columnName) {
1487
1487
  const root = el.shadowRoot;
1488
1488
  if (root == null) return 176;
1489
1489
  const escaped = cssEscape(columnName);
1490
- const selectors = [
1491
- `.header-cell[data-column="${escaped}"]`,
1492
- `.filter-cell[data-column="${escaped}"]`,
1493
- `.body-cell[data-column="${escaped}"] .cell-shell`
1494
- ];
1490
+ const probe = document.createElement("div");
1491
+ probe.style.cssText = "position:absolute;top:-9999px;left:-9999px;width:max-content;visibility:hidden;";
1492
+ root.appendChild(probe);
1495
1493
  let maxWidth = 0;
1496
- for (const selector of selectors) {
1497
- for (const target of root.querySelectorAll(selector)) {
1498
- maxWidth = Math.max(maxWidth, target.scrollWidth);
1499
- }
1500
- }
1501
- return maxWidth + 12;
1494
+ for (const header of root.querySelectorAll(`.header-cell[data-column="${escaped}"]`)) {
1495
+ const clone = header.cloneNode(true);
1496
+ clone.style.cssText = "display:grid;grid-template-columns:auto auto;gap:inherit;padding:inherit;width:max-content;";
1497
+ probe.appendChild(clone);
1498
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
1499
+ probe.removeChild(clone);
1500
+ }
1501
+ for (const filter of root.querySelectorAll(`ui-grid-filter-cell[data-column="${escaped}"], .filter-cell[data-column="${escaped}"]`)) {
1502
+ const clone = filter.cloneNode(true);
1503
+ clone.style.cssText = "display:block;width:max-content;";
1504
+ probe.appendChild(clone);
1505
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
1506
+ probe.removeChild(clone);
1507
+ }
1508
+ for (const cell of root.querySelectorAll(`.body-cell[data-column="${escaped}"]`)) {
1509
+ const shell = cell.querySelector(".cell-shell");
1510
+ if (!shell) continue;
1511
+ const clone = shell.cloneNode(true);
1512
+ clone.style.cssText = "display:flex;width:max-content;";
1513
+ probe.appendChild(clone);
1514
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
1515
+ probe.removeChild(clone);
1516
+ }
1517
+ root.removeChild(probe);
1518
+ return Math.max(88, maxWidth + 12);
1502
1519
  }
1503
1520
 
1504
1521
  // src/events.ts
@@ -1970,6 +1987,11 @@ function bindEvents(el) {
1970
1987
  event.preventDefault();
1971
1988
  return;
1972
1989
  }
1990
+ const colDef = el.snapshot.visibleColumns.find((c) => c.name === columnName);
1991
+ if (colDef?.enableColumnMoving === false) {
1992
+ event.preventDefault();
1993
+ return;
1994
+ }
1973
1995
  el.draggedColumnName = columnName;
1974
1996
  el.dropTargetColumnName = null;
1975
1997
  dragEvent.dataTransfer.effectAllowed = "move";
@@ -1983,6 +2005,8 @@ function bindEvents(el) {
1983
2005
  if (!headerCell || !el.draggedColumnName || !dragEvent.dataTransfer) return;
1984
2006
  const columnName = headerCell.dataset["column"];
1985
2007
  if (!columnName || columnName === el.draggedColumnName) return;
2008
+ const colDef = el.snapshot?.visibleColumns.find((c) => c.name === columnName);
2009
+ if (colDef?.enableColumnMoving === false) return;
1986
2010
  event.preventDefault();
1987
2011
  dragEvent.dataTransfer.dropEffect = "move";
1988
2012
  if (el.dropTargetColumnName !== columnName) {
@@ -2229,8 +2253,72 @@ import {
2229
2253
  function orderVisibleColumns(columns, order) {
2230
2254
  return [...columns].filter((column) => column.visible !== false).sort((left, right) => order.indexOf(left.name) - order.indexOf(right.name));
2231
2255
  }
2232
- function buildGridTemplateColumns(columns) {
2233
- return columns.map((column) => gridColumnWidth(column)).join(" ");
2256
+ var DEFAULT_COL_MIN_WIDTH = 176;
2257
+ function buildGridTemplateColumns(columns, viewportWidth) {
2258
+ if (!viewportWidth || viewportWidth <= 0) {
2259
+ return columns.map((column) => gridColumnWidth(column)).join(" ");
2260
+ }
2261
+ const availableWidth = viewportWidth;
2262
+ const resolved = new Array(columns.length);
2263
+ const flexIndices = [];
2264
+ let usedWidth = 0;
2265
+ for (let i = 0; i < columns.length; i++) {
2266
+ const raw = columns[i].width;
2267
+ const minWidth = columns[i].minWidth;
2268
+ const maxWidth = columns[i].maxWidth ?? Infinity;
2269
+ if (raw && raw.endsWith("px")) {
2270
+ let px = parseInt(raw, 10);
2271
+ if (minWidth != null) px = Math.max(minWidth, px);
2272
+ px = Math.min(maxWidth, px);
2273
+ resolved[i] = px;
2274
+ usedWidth += px;
2275
+ } else if (raw && raw.endsWith("%")) {
2276
+ const pct = parseInt(raw.replace(/%/g, ""), 10);
2277
+ let w = Math.round(pct / 100 * availableWidth);
2278
+ if (minWidth != null) w = Math.max(minWidth, w);
2279
+ w = Math.min(maxWidth, w);
2280
+ resolved[i] = w;
2281
+ usedWidth += w;
2282
+ } else {
2283
+ flexIndices.push(i);
2284
+ resolved[i] = 0;
2285
+ }
2286
+ }
2287
+ if (flexIndices.length > 0) {
2288
+ const remaining = Math.max(0, availableWidth - usedWidth);
2289
+ const perFlex = Math.floor(remaining / flexIndices.length);
2290
+ for (const idx of flexIndices) {
2291
+ const minWidth = columns[idx].minWidth ?? DEFAULT_COL_MIN_WIDTH;
2292
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
2293
+ const w = Math.max(minWidth, Math.min(maxWidth, perFlex));
2294
+ resolved[idx] = w;
2295
+ usedWidth += w;
2296
+ }
2297
+ let leftover = availableWidth - usedWidth;
2298
+ let changed = true;
2299
+ while (leftover > 0 && changed) {
2300
+ changed = false;
2301
+ for (const idx of flexIndices) {
2302
+ if (leftover <= 0) break;
2303
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
2304
+ if (resolved[idx] < maxWidth) {
2305
+ resolved[idx]++;
2306
+ leftover--;
2307
+ changed = true;
2308
+ }
2309
+ }
2310
+ }
2311
+ }
2312
+ return resolved.map((w) => `${w}px`).join(" ");
2313
+ }
2314
+ function pinnedStatesEqual(a, b) {
2315
+ const keysA = Object.keys(a);
2316
+ const keysB = Object.keys(b);
2317
+ if (keysA.length !== keysB.length) return false;
2318
+ for (const key of keysA) {
2319
+ if (a[key] !== b[key]) return false;
2320
+ }
2321
+ return true;
2234
2322
  }
2235
2323
  var VanillaGridController = class {
2236
2324
  options;
@@ -2240,6 +2328,10 @@ var VanillaGridController = class {
2240
2328
  direction: SORT_DIRECTIONS2.none
2241
2329
  };
2242
2330
  groupByColumns = [];
2331
+ lastOptionsGroupBy = void 0;
2332
+ lastOptionsPinnedState = void 0;
2333
+ lastOptionsInfiniteScrollUp = void 0;
2334
+ lastOptionsInfiniteScrollDown = void 0;
2243
2335
  collapsedGroups = {};
2244
2336
  hiddenRowReasons = {};
2245
2337
  expandedRows = {};
@@ -2247,6 +2339,7 @@ var VanillaGridController = class {
2247
2339
  pinnedColumns = {};
2248
2340
  columnOrder = [];
2249
2341
  columnWidthOverrides = {};
2342
+ viewportWidth = 0;
2250
2343
  currentPage = 1;
2251
2344
  pageSize = 0;
2252
2345
  editingCell = null;
@@ -2283,7 +2376,12 @@ var VanillaGridController = class {
2283
2376
  this.validatorRegistry = createGridValidatorRegistry(this.labels);
2284
2377
  this.columnOrder = options.columnDefs.map((column) => column.name);
2285
2378
  this.groupByColumns = options.grouping?.groupBy ? [...options.grouping.groupBy] : [];
2379
+ this.lastOptionsGroupBy = options.grouping?.groupBy ? [...options.grouping.groupBy] : void 0;
2286
2380
  this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
2381
+ const declaresPin = options.columnDefs.some((col) => col.pinnedLeft === true || col.pinnedRight === true);
2382
+ this.lastOptionsPinnedState = declaresPin ? { ...this.pinnedColumns } : void 0;
2383
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
2384
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
2287
2385
  this.infiniteScrollState = resetInfiniteScrollState(
2288
2386
  options.infiniteScrollUp === true,
2289
2387
  options.infiniteScrollDown !== false
@@ -2500,20 +2598,41 @@ var VanillaGridController = class {
2500
2598
  }
2501
2599
  }
2502
2600
  if (options.grouping?.groupBy !== void 0) {
2503
- this.groupByColumns = [...options.grouping.groupBy];
2601
+ const incoming = options.grouping.groupBy;
2602
+ const prev = this.lastOptionsGroupBy;
2603
+ const changed = !prev || prev.length !== incoming.length || prev.some((col, i) => col !== incoming[i]);
2604
+ if (changed) {
2605
+ this.groupByColumns = [...incoming];
2606
+ this.lastOptionsGroupBy = [...incoming];
2607
+ }
2608
+ } else if (this.lastOptionsGroupBy !== void 0) {
2609
+ this.lastOptionsGroupBy = void 0;
2504
2610
  }
2505
2611
  const declaresPin = options.columnDefs.some(
2506
2612
  (col) => col.pinnedLeft === true || col.pinnedRight === true
2507
2613
  );
2508
2614
  if (declaresPin) {
2509
- this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
2615
+ const incoming = buildInitialPinnedState(options.columnDefs);
2616
+ const prev = this.lastOptionsPinnedState;
2617
+ if (!prev || !pinnedStatesEqual(prev, incoming)) {
2618
+ this.pinnedColumns = incoming;
2619
+ this.lastOptionsPinnedState = { ...incoming };
2620
+ }
2621
+ } else if (this.lastOptionsPinnedState !== void 0) {
2622
+ this.lastOptionsPinnedState = void 0;
2510
2623
  }
2511
2624
  if (options.infiniteScrollUp !== void 0 || options.infiniteScrollDown !== void 0) {
2512
- this.infiniteScrollState = setInfiniteScrollDirectionsState(
2513
- this.infiniteScrollState,
2514
- options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
2515
- options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown
2516
- );
2625
+ const upChanged = options.infiniteScrollUp !== void 0 && options.infiniteScrollUp !== this.lastOptionsInfiniteScrollUp;
2626
+ const downChanged = options.infiniteScrollDown !== void 0 && options.infiniteScrollDown !== this.lastOptionsInfiniteScrollDown;
2627
+ if (upChanged || downChanged) {
2628
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
2629
+ this.infiniteScrollState,
2630
+ options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
2631
+ options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown
2632
+ );
2633
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
2634
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
2635
+ }
2517
2636
  }
2518
2637
  this.apiRegistered = false;
2519
2638
  this.refresh();
@@ -2555,7 +2674,7 @@ var VanillaGridController = class {
2555
2674
  labels: this.labels,
2556
2675
  rowSize: this.getRowSize(),
2557
2676
  visibleColumns: this.visibleColumns,
2558
- gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns),
2677
+ gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns, this.viewportWidth),
2559
2678
  pipeline: this.pipeline,
2560
2679
  activeFilters: this.activeFilters,
2561
2680
  sortState: this.sortState,
@@ -3206,6 +3325,12 @@ var VanillaGridController = class {
3206
3325
  canResizeColumns() {
3207
3326
  return this.options.enableColumnResizing !== false;
3208
3327
  }
3328
+ setViewportWidth(width) {
3329
+ this.viewportWidth = Math.round(width);
3330
+ }
3331
+ getViewportWidth() {
3332
+ return this.viewportWidth;
3333
+ }
3209
3334
  setColumnWidthOverride(columnName, widthPx) {
3210
3335
  const nextWidth = `${Math.max(88, Math.round(widthPx))}px`;
3211
3336
  this.columnWidthOverrides = { ...this.columnWidthOverrides, [columnName]: nextWidth };
@@ -3218,7 +3343,8 @@ var VanillaGridController = class {
3218
3343
  return buildGridTemplateColumns(
3219
3344
  this.visibleColumns.map(
3220
3345
  (c) => c.name === columnName ? { ...c, width: widthStr } : c
3221
- )
3346
+ ),
3347
+ this.viewportWidth
3222
3348
  );
3223
3349
  }
3224
3350
  isGroupingEnabled() {
@@ -3455,11 +3581,15 @@ var VanillaGridController = class {
3455
3581
  name: "selectionRowHeaderCol",
3456
3582
  displayName: "",
3457
3583
  width: `${resolvedSelection.selectionRowHeaderWidth}px`,
3584
+ minWidth: resolvedSelection.selectionRowHeaderWidth,
3585
+ maxWidth: resolvedSelection.selectionRowHeaderWidth,
3458
3586
  enableSorting: false,
3459
3587
  enableFiltering: false,
3460
3588
  enableGrouping: false,
3461
3589
  enableCellEdit: false,
3462
3590
  enablePinning: false,
3591
+ enableColumnResizing: false,
3592
+ enableColumnMoving: false,
3463
3593
  align: "center"
3464
3594
  };
3465
3595
  if (!this.visibleColumns.some((c) => c.name === "selectionRowHeaderCol")) {
@@ -5136,10 +5266,16 @@ var UiGridStandaloneElement = class extends HTMLElement {
5136
5266
  /** @internal */
5137
5267
  ensureController(options) {
5138
5268
  if (this.controller) {
5269
+ if (this.clientWidth > 0) {
5270
+ this.controller.setViewportWidth(this.clientWidth);
5271
+ }
5139
5272
  this.controller.setOptions(options);
5140
5273
  return;
5141
5274
  }
5142
5275
  this.controller = createVanillaGridController(options);
5276
+ if (this.clientWidth > 0) {
5277
+ this.controller.setViewportWidth(this.clientWidth);
5278
+ }
5143
5279
  this.controller.setCellNavScrollHandler((rowId, columnName) => {
5144
5280
  if (!rowId || !columnName) return;
5145
5281
  this.scrollFocusedRowIntoView(rowId);
@@ -5273,6 +5409,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
5273
5409
  virtualBody.innerHTML = itemsToRender.map((item, index) => this.renderDisplayItem(item, startIndex + index)).join("");
5274
5410
  this.lastItemsFingerprint = this.fingerprintItems(itemsToRender);
5275
5411
  this.lastVirtualOffset = virtualOffset;
5412
+ this.frameworkSlots.flush();
5276
5413
  }
5277
5414
  /** @internal */
5278
5415
  render() {
@@ -5372,10 +5509,12 @@ var UiGridStandaloneElement = class extends HTMLElement {
5372
5509
  if (this.autoResizeObserver) return;
5373
5510
  if (typeof ResizeObserver === "undefined") return;
5374
5511
  this.autoResizeObserver = new ResizeObserver((entries) => {
5375
- const options = this.snapshot?.options;
5376
- if (options?.enableAutoResize === false) return;
5377
5512
  const entry = entries[0];
5378
5513
  if (!entry) return;
5514
+ const newWidth = entry.contentRect.width;
5515
+ if (this.controller && newWidth > 0) {
5516
+ this.controller.setViewportWidth(newWidth);
5517
+ }
5379
5518
  if (this.autoResizeDebounceHandle !== null) {
5380
5519
  cancelAnimationFrame(this.autoResizeDebounceHandle);
5381
5520
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornery/ui-grid-vanilla",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Vanilla DOM wrapper for the Rust/WASM-backed @ornery/ui-grid-core custom element",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -160,13 +160,14 @@ describe('enableAutoResize (ResizeObserver)', () => {
160
160
  expect(renderSpy).toHaveBeenCalledTimes(1);
161
161
  });
162
162
 
163
- it('does not trigger render when enableAutoResize is false', () => {
163
+ it('still re-renders on resize even with enableAutoResize false (column widths need recalculation)', () => {
164
164
  document.body.appendChild(element);
165
165
  element.options = {
166
166
  id: 'test-grid',
167
167
  data: [{ name: 'Alice' }],
168
168
  columnDefs: [{ name: 'name' }],
169
169
  enableAutoResize: false,
170
+ enableMinHeightCheck: false,
170
171
  };
171
172
 
172
173
  const renderSpy = vi.spyOn(element as any, 'render');
@@ -177,8 +178,8 @@ describe('enableAutoResize (ResizeObserver)', () => {
177
178
 
178
179
  flushRaf();
179
180
 
180
- // The observer callback bails when enableAutoResize is false
181
- expect(renderSpy).not.toHaveBeenCalled();
181
+ // Resize always triggers render for column width recalculation
182
+ expect(renderSpy).toHaveBeenCalledTimes(1);
182
183
  });
183
184
 
184
185
  it('cancels pending rAF on teardown', () => {
package/src/events.ts CHANGED
@@ -664,6 +664,12 @@ export function bindEvents(el: UiGridStandaloneElement): void {
664
664
  return;
665
665
  }
666
666
 
667
+ const colDef = el.snapshot.visibleColumns.find((c) => c.name === columnName);
668
+ if (colDef?.enableColumnMoving === false) {
669
+ event.preventDefault();
670
+ return;
671
+ }
672
+
667
673
  el.draggedColumnName = columnName;
668
674
  el.dropTargetColumnName = null;
669
675
  dragEvent.dataTransfer.effectAllowed = 'move';
@@ -681,6 +687,9 @@ export function bindEvents(el: UiGridStandaloneElement): void {
681
687
  const columnName = headerCell.dataset['column'];
682
688
  if (!columnName || columnName === el.draggedColumnName) return;
683
689
 
690
+ const colDef = el.snapshot?.visibleColumns.find((c) => c.name === columnName);
691
+ if (colDef?.enableColumnMoving === false) return;
692
+
684
693
  event.preventDefault();
685
694
  dragEvent.dataTransfer.dropEffect = 'move';
686
695
 
package/src/focus.ts CHANGED
@@ -329,25 +329,51 @@ export function matchesKeyOverride(
329
329
  }
330
330
 
331
331
  /**
332
- * Measure the widest `scrollWidth` across a column's header / filter /
333
- * body cells so the auto-resize double-click on the header divider can
334
- * size the column to its actual rendered content. Returns a px value
335
- * (plus a small padding fudge).
332
+ * Measure the natural content width of a column by cloning visible cells
333
+ * into a hidden max-content container. This avoids measuring the CSS Grid
334
+ * track width (which just reflects the current column size) and instead
335
+ * returns the width the content actually needs.
336
336
  */
337
337
  export function measureAutoColumnWidth(el: UiGridStandaloneElement, columnName: string): number {
338
338
  const root = el.shadowRoot;
339
339
  if (root == null) return 176;
340
340
  const escaped = cssEscape(columnName);
341
- const selectors = [
342
- `.header-cell[data-column="${escaped}"]`,
343
- `.filter-cell[data-column="${escaped}"]`,
344
- `.body-cell[data-column="${escaped}"] .cell-shell`,
345
- ];
341
+
342
+ const probe = document.createElement('div');
343
+ probe.style.cssText = 'position:absolute;top:-9999px;left:-9999px;width:max-content;visibility:hidden;';
344
+ root.appendChild(probe);
345
+
346
346
  let maxWidth = 0;
347
- for (const selector of selectors) {
348
- for (const target of root.querySelectorAll<HTMLElement>(selector)) {
349
- maxWidth = Math.max(maxWidth, target.scrollWidth);
350
- }
347
+
348
+ // Header cell: clone and measure at natural width.
349
+ for (const header of root.querySelectorAll<HTMLElement>(`.header-cell[data-column="${escaped}"]`)) {
350
+ const clone = header.cloneNode(true) as HTMLElement;
351
+ clone.style.cssText = 'display:grid;grid-template-columns:auto auto;gap:inherit;padding:inherit;width:max-content;';
352
+ probe.appendChild(clone);
353
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
354
+ probe.removeChild(clone);
351
355
  }
352
- return maxWidth + 12;
356
+
357
+ // Filter cell: both custom element and class-based fallback.
358
+ for (const filter of root.querySelectorAll<HTMLElement>(`ui-grid-filter-cell[data-column="${escaped}"], .filter-cell[data-column="${escaped}"]`)) {
359
+ const clone = filter.cloneNode(true) as HTMLElement;
360
+ clone.style.cssText = 'display:block;width:max-content;';
361
+ probe.appendChild(clone);
362
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
363
+ probe.removeChild(clone);
364
+ }
365
+
366
+ // Body cells: clone .cell-shell content and measure.
367
+ for (const cell of root.querySelectorAll<HTMLElement>(`.body-cell[data-column="${escaped}"]`)) {
368
+ const shell = cell.querySelector<HTMLElement>('.cell-shell');
369
+ if (!shell) continue;
370
+ const clone = shell.cloneNode(true) as HTMLElement;
371
+ clone.style.cssText = 'display:flex;width:max-content;';
372
+ probe.appendChild(clone);
373
+ maxWidth = Math.max(maxWidth, clone.scrollWidth);
374
+ probe.removeChild(clone);
375
+ }
376
+
377
+ root.removeChild(probe);
378
+ return Math.max(88, maxWidth + 12);
353
379
  }
@@ -201,8 +201,94 @@ function orderVisibleColumns(
201
201
  .sort((left, right) => order.indexOf(left.name) - order.indexOf(right.name));
202
202
  }
203
203
 
204
- function buildGridTemplateColumns(columns: readonly GridColumnDef[]): string {
205
- return columns.map((column) => gridColumnWidth(column)).join(' ');
204
+ const DEFAULT_COL_MIN_WIDTH = 176;
205
+
206
+ /**
207
+ * Resolve column widths to fixed px values, mirroring the old ui-grid's
208
+ * `updateColumnWidths` / `drawnWidth` approach. Every cell (header, filter,
209
+ * body) gets the exact same px track size — alignment guaranteed regardless
210
+ * of container width or scrollbar presence.
211
+ *
212
+ * Algorithm (ported from GridRenderContainer.prototype.updateColumnWidths):
213
+ * 1. Fixed-px columns: use their declared width directly.
214
+ * 2. Percentage columns: resolve against availableWidth.
215
+ * 3. Flex columns (*, 1fr, minmax): divide remaining space equally.
216
+ * 4. Leftover/excess pixels distributed 1px at a time for pixel-perfect fit.
217
+ */
218
+ function buildGridTemplateColumns(columns: readonly GridColumnDef[], viewportWidth?: number): string {
219
+ if (!viewportWidth || viewportWidth <= 0) {
220
+ return columns.map((column) => gridColumnWidth(column)).join(' ');
221
+ }
222
+
223
+ const availableWidth = viewportWidth;
224
+ const resolved: number[] = new Array(columns.length);
225
+ const flexIndices: number[] = [];
226
+ let usedWidth = 0;
227
+
228
+ for (let i = 0; i < columns.length; i++) {
229
+ const raw = columns[i].width;
230
+ const minWidth = columns[i].minWidth;
231
+ const maxWidth = columns[i].maxWidth ?? Infinity;
232
+
233
+ if (raw && raw.endsWith('px')) {
234
+ let px = parseInt(raw, 10);
235
+ if (minWidth != null) px = Math.max(minWidth, px);
236
+ px = Math.min(maxWidth, px);
237
+ resolved[i] = px;
238
+ usedWidth += px;
239
+ } else if (raw && raw.endsWith('%')) {
240
+ const pct = parseInt(raw.replace(/%/g, ''), 10);
241
+ let w = Math.round((pct / 100) * availableWidth);
242
+ if (minWidth != null) w = Math.max(minWidth, w);
243
+ w = Math.min(maxWidth, w);
244
+ resolved[i] = w;
245
+ usedWidth += w;
246
+ } else {
247
+ flexIndices.push(i);
248
+ resolved[i] = 0;
249
+ }
250
+ }
251
+
252
+ if (flexIndices.length > 0) {
253
+ const remaining = Math.max(0, availableWidth - usedWidth);
254
+ const perFlex = Math.floor(remaining / flexIndices.length);
255
+
256
+ for (const idx of flexIndices) {
257
+ const minWidth = columns[idx].minWidth ?? DEFAULT_COL_MIN_WIDTH;
258
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
259
+ const w = Math.max(minWidth, Math.min(maxWidth, perFlex));
260
+ resolved[idx] = w;
261
+ usedWidth += w;
262
+ }
263
+
264
+ // Distribute leftover pixels one at a time (old grid's processColumnUpwards)
265
+ let leftover = availableWidth - usedWidth;
266
+ let changed = true;
267
+ while (leftover > 0 && changed) {
268
+ changed = false;
269
+ for (const idx of flexIndices) {
270
+ if (leftover <= 0) break;
271
+ const maxWidth = columns[idx].maxWidth ?? Infinity;
272
+ if (resolved[idx] < maxWidth) {
273
+ resolved[idx]++;
274
+ leftover--;
275
+ changed = true;
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ return resolved.map((w) => `${w}px`).join(' ');
282
+ }
283
+
284
+ function pinnedStatesEqual(a: PinnedColumnState, b: PinnedColumnState): boolean {
285
+ const keysA = Object.keys(a);
286
+ const keysB = Object.keys(b);
287
+ if (keysA.length !== keysB.length) return false;
288
+ for (const key of keysA) {
289
+ if (a[key] !== b[key]) return false;
290
+ }
291
+ return true;
206
292
  }
207
293
 
208
294
  export class VanillaGridController {
@@ -213,6 +299,10 @@ export class VanillaGridController {
213
299
  direction: SORT_DIRECTIONS.none,
214
300
  };
215
301
  private groupByColumns: string[] = [];
302
+ private lastOptionsGroupBy: string[] | undefined = undefined;
303
+ private lastOptionsPinnedState: PinnedColumnState | undefined = undefined;
304
+ private lastOptionsInfiniteScrollUp: boolean | undefined = undefined;
305
+ private lastOptionsInfiniteScrollDown: boolean | undefined = undefined;
216
306
  private collapsedGroups: Record<string, boolean> = {};
217
307
  private hiddenRowReasons: Record<string, string[]> = {};
218
308
  private expandedRows: Record<string, boolean> = {};
@@ -220,6 +310,7 @@ export class VanillaGridController {
220
310
  private pinnedColumns: PinnedColumnState = {};
221
311
  private columnOrder: string[] = [];
222
312
  private columnWidthOverrides: Record<string, string> = {};
313
+ private viewportWidth = 0;
223
314
  private currentPage = 1;
224
315
  private pageSize = 0;
225
316
  private editingCell: GridCellPosition | null = null;
@@ -262,7 +353,12 @@ export class VanillaGridController {
262
353
  this.validatorRegistry = createGridValidatorRegistry(this.labels);
263
354
  this.columnOrder = options.columnDefs.map((column) => column.name);
264
355
  this.groupByColumns = options.grouping?.groupBy ? [...options.grouping.groupBy] : [];
356
+ this.lastOptionsGroupBy = options.grouping?.groupBy ? [...options.grouping.groupBy] : undefined;
265
357
  this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
358
+ const declaresPin = options.columnDefs.some((col) => col.pinnedLeft === true || col.pinnedRight === true);
359
+ this.lastOptionsPinnedState = declaresPin ? { ...this.pinnedColumns } : undefined;
360
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
361
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
266
362
 
267
363
  this.infiniteScrollState = resetInfiniteScrollState(
268
364
  options.infiniteScrollUp === true,
@@ -519,35 +615,53 @@ export class VanillaGridController {
519
615
  }
520
616
  }
521
617
 
522
- // Apply declarative grouping from options when provided. Matches the
523
- // Angular element's behaviour — the consumer can set
524
- // `grouping: { groupBy: [...] }` and it takes effect immediately.
525
- // When the option is absent, leave the current groupByColumns untouched
526
- // so interactive toggles persist across non-grouping option updates.
618
+ // Apply declarative grouping only when the consumer's value structurally
619
+ // changed from what was last provided via options. This lets interactive
620
+ // toggles persist across re-renders that pass the same grouping config.
527
621
  if (options.grouping?.groupBy !== undefined) {
528
- this.groupByColumns = [...options.grouping.groupBy];
622
+ const incoming = options.grouping.groupBy;
623
+ const prev = this.lastOptionsGroupBy;
624
+ const changed = !prev || prev.length !== incoming.length || prev.some((col, i) => col !== incoming[i]);
625
+ if (changed) {
626
+ this.groupByColumns = [...incoming];
627
+ this.lastOptionsGroupBy = [...incoming];
628
+ }
629
+ } else if (this.lastOptionsGroupBy !== undefined) {
630
+ this.lastOptionsGroupBy = undefined;
529
631
  }
530
632
 
531
- // Re-seed pinned state from columnDefs whenever a columnDef declares
532
- // `pinnedLeft` / `pinnedRight`. This mirrors the Angular element, which
533
- // re-derives pinned state on every options update, so switching demo
534
- // scenarios (different column sets) picks up their declarative pins.
633
+ // Re-seed pinned state from columnDefs only when the declarative pin
634
+ // configuration structurally changed from the last options pass. This
635
+ // prevents interactive un-pin actions from being clobbered by re-renders
636
+ // that pass the same columnDefs.
535
637
  const declaresPin = options.columnDefs.some(
536
638
  (col) => col.pinnedLeft === true || col.pinnedRight === true,
537
639
  );
538
640
  if (declaresPin) {
539
- this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
641
+ const incoming = buildInitialPinnedState(options.columnDefs);
642
+ const prev = this.lastOptionsPinnedState;
643
+ if (!prev || !pinnedStatesEqual(prev, incoming)) {
644
+ this.pinnedColumns = incoming;
645
+ this.lastOptionsPinnedState = { ...incoming };
646
+ }
647
+ } else if (this.lastOptionsPinnedState !== undefined) {
648
+ this.lastOptionsPinnedState = undefined;
540
649
  }
541
650
 
542
- // Re-seed infinite-scroll directions when the options declare them,
543
- // so consumers can flip scrollUp/scrollDown through `grid.options`
544
- // without needing to go through setScrollDirections.
651
+ // Re-seed infinite-scroll directions only when the values structurally
652
+ // changed from the last options pass.
545
653
  if (options.infiniteScrollUp !== undefined || options.infiniteScrollDown !== undefined) {
546
- this.infiniteScrollState = setInfiniteScrollDirectionsState(
547
- this.infiniteScrollState,
548
- options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
549
- options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown,
550
- );
654
+ const upChanged = options.infiniteScrollUp !== undefined && options.infiniteScrollUp !== this.lastOptionsInfiniteScrollUp;
655
+ const downChanged = options.infiniteScrollDown !== undefined && options.infiniteScrollDown !== this.lastOptionsInfiniteScrollDown;
656
+ if (upChanged || downChanged) {
657
+ this.infiniteScrollState = setInfiniteScrollDirectionsState(
658
+ this.infiniteScrollState,
659
+ options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
660
+ options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown,
661
+ );
662
+ this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
663
+ this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
664
+ }
551
665
  }
552
666
 
553
667
  this.apiRegistered = false;
@@ -593,7 +707,7 @@ export class VanillaGridController {
593
707
  labels: this.labels,
594
708
  rowSize: this.getRowSize(),
595
709
  visibleColumns: this.visibleColumns,
596
- gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns),
710
+ gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns, this.viewportWidth),
597
711
  pipeline: this.pipeline,
598
712
  activeFilters: this.activeFilters,
599
713
  sortState: this.sortState,
@@ -1377,6 +1491,14 @@ export class VanillaGridController {
1377
1491
  return this.options.enableColumnResizing !== false;
1378
1492
  }
1379
1493
 
1494
+ setViewportWidth(width: number): void {
1495
+ this.viewportWidth = Math.round(width);
1496
+ }
1497
+
1498
+ getViewportWidth(): number {
1499
+ return this.viewportWidth;
1500
+ }
1501
+
1380
1502
  setColumnWidthOverride(columnName: string, widthPx: number): void {
1381
1503
  const nextWidth = `${Math.max(88, Math.round(widthPx))}px`;
1382
1504
  this.columnWidthOverrides = { ...this.columnWidthOverrides, [columnName]: nextWidth };
@@ -1391,6 +1513,7 @@ export class VanillaGridController {
1391
1513
  this.visibleColumns.map((c) =>
1392
1514
  c.name === columnName ? { ...c, width: widthStr } : c,
1393
1515
  ),
1516
+ this.viewportWidth,
1394
1517
  );
1395
1518
  }
1396
1519
 
@@ -1691,11 +1814,15 @@ export class VanillaGridController {
1691
1814
  name: 'selectionRowHeaderCol',
1692
1815
  displayName: '',
1693
1816
  width: `${resolvedSelection.selectionRowHeaderWidth}px`,
1817
+ minWidth: resolvedSelection.selectionRowHeaderWidth,
1818
+ maxWidth: resolvedSelection.selectionRowHeaderWidth,
1694
1819
  enableSorting: false,
1695
1820
  enableFiltering: false,
1696
1821
  enableGrouping: false,
1697
1822
  enableCellEdit: false,
1698
1823
  enablePinning: false,
1824
+ enableColumnResizing: false,
1825
+ enableColumnMoving: false,
1699
1826
  align: 'center',
1700
1827
  };
1701
1828
  // Dedupe if already present (setOptions could be re-entering).
package/src/render.ts CHANGED
@@ -105,7 +105,7 @@ export function renderHeaderCell(
105
105
  const pinLabel = isPinned
106
106
  ? (el.snapshot?.labels.unpin ?? 'Unpin')
107
107
  : (el.snapshot?.labels.pinColumn ?? 'Pin');
108
- const canResize = controller.canResizeColumns();
108
+ const canResize = controller.canResizeColumns() && column.enableColumnResizing !== false;
109
109
  const headerValue = escapeHtml(formatGridHeaderDisplayValue(buildGridHeaderContext(column)));
110
110
  const resizerHtml = canResize
111
111
  ? resizerMarkup(escapeHtml(column.name), escapeHtml(headerValue))
@@ -116,7 +116,7 @@ export function renderHeaderCell(
116
116
  const isPinMenuOpen = el.openPinMenuColumn === column.name;
117
117
  const isDragTarget = el.dropTargetColumnName === column.name;
118
118
  const isDragging = el.draggedColumnName === column.name;
119
- const isDraggable = canGridMoveColumns(options);
119
+ const isDraggable = canGridMoveColumns(options) && column.enableColumnMoving !== false;
120
120
  const className = headerCellClass(
121
121
  sortDirection !== SORT_DIRECTIONS.none,
122
122
  isPinned,
@@ -709,11 +709,17 @@ export class UiGridStandaloneElement extends HTMLElement {
709
709
  /** @internal */
710
710
  ensureController(options: VanillaGridOptions): void {
711
711
  if (this.controller) {
712
+ if (this.clientWidth > 0) {
713
+ this.controller.setViewportWidth(this.clientWidth);
714
+ }
712
715
  this.controller.setOptions(options);
713
716
  return;
714
717
  }
715
718
 
716
719
  this.controller = createVanillaGridController(options);
720
+ if (this.clientWidth > 0) {
721
+ this.controller.setViewportWidth(this.clientWidth);
722
+ }
717
723
  // Let the controller's cellNav.scrollToFocus delegate into our
718
724
  // element's scroll-and-focus helpers.
719
725
  this.controller.setCellNavScrollHandler((rowId, columnName) => {
@@ -883,6 +889,8 @@ export class UiGridStandaloneElement extends HTMLElement {
883
889
  // next full render() can take the fast per-cell patch path.
884
890
  this.lastItemsFingerprint = this.fingerprintItems(itemsToRender);
885
891
  this.lastVirtualOffset = virtualOffset;
892
+
893
+ this.frameworkSlots.flush();
886
894
  }
887
895
 
888
896
  /** @internal */
@@ -1011,12 +1019,14 @@ export class UiGridStandaloneElement extends HTMLElement {
1011
1019
  if (typeof ResizeObserver === 'undefined') return;
1012
1020
 
1013
1021
  this.autoResizeObserver = new ResizeObserver((entries) => {
1014
- const options = this.snapshot?.options;
1015
- if (options?.enableAutoResize === false) return;
1016
-
1017
1022
  const entry = entries[0];
1018
1023
  if (!entry) return;
1019
1024
 
1025
+ const newWidth = entry.contentRect.width;
1026
+ if (this.controller && newWidth > 0) {
1027
+ this.controller.setViewportWidth(newWidth);
1028
+ }
1029
+
1020
1030
  if (this.autoResizeDebounceHandle !== null) {
1021
1031
  cancelAnimationFrame(this.autoResizeDebounceHandle);
1022
1032
  }