@rozie-ui/data-table-lit 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/DataTable.ts CHANGED
@@ -10,6 +10,7 @@ import { columnRuleKeys } from './helpers/columnRuleUtils';
10
10
  import { applyUpdater, clamp, focusables } from './helpers/indexMath';
11
11
  import { resolveFollow, sameIdSequence } from './helpers/activeFollow';
12
12
  import { escapeTsvField, parseTsv, tileGridToBox, tileIndex } from './helpers/tsvGrid';
13
+ import { rangeFillTargets } from './helpers/rangeFill';
13
14
  import { replaceRowValue, indexOfRowIn, replaceRowValues } from './helpers/rowValueUtils';
14
15
  import { createTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel, getGroupedRowModel,
15
16
  // Faceted filtering (phase 50 reqs 8-9, D-03). All three are supplied UNCONDITIONALLY
@@ -213,10 +214,13 @@ interface RozieDynamicEditorSlotCtx {
213
214
  column: TableColumn<any, unknown>;
214
215
  row: any;
215
216
  value: any;
216
- commit: (value: any) => void;
217
+ commit: (value: any, options?: {
218
+ toRange?: boolean;
219
+ }) => void;
217
220
  cancel: () => void;
218
221
  columnLabel: string;
219
222
  autofocus: boolean;
223
+ rangeSize: number;
220
224
  }
221
225
 
222
226
  interface RozieDetailSlotCtx {
@@ -250,10 +254,13 @@ interface RozieEditorSlotCtx {
250
254
  column: TableColumn<any, unknown>;
251
255
  row: any;
252
256
  value: any;
253
- commit: (value: any) => void;
257
+ commit: (value: any, options?: {
258
+ toRange?: boolean;
259
+ }) => void;
254
260
  cancel: () => void;
255
261
  columnLabel: string;
256
262
  autofocus: boolean;
263
+ rangeSize: number;
257
264
  }
258
265
 
259
266
  @customElement('rozie-data-table')
@@ -268,6 +275,8 @@ export default class DataTable extends SignalWatcher(LitElement) {
268
275
  --rdt-border: 1px solid var(--rozie-data-table-border-color, var(--rdt-ds-border-color));
269
276
  --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color, var(--rdt-ds-control-border-color));
270
277
  --rdt-filter-radius: var(--rozie-data-table-radius, var(--rdt-ds-radius));
278
+ --rdt-column-border: 1px solid var(--rozie-data-table-column-border-color, var(--rozie-data-table-border-color, var(--rdt-ds-column-border-color)));
279
+ --rdt-pinned-bg: var(--rozie-data-table-pinned-bg, var(--rdt-ds-pinned-bg));
271
280
  --rdt-font: var(--rozie-data-table-font, var(--rdt-ds-font));
272
281
  --rdt-cell-padding: var(--rozie-data-table-cell-padding, var(--rdt-ds-cell-padding));
273
282
  --rdt-header-bg: var(--rozie-data-table-header-bg, var(--rdt-ds-header-bg));
@@ -289,7 +298,12 @@ export default class DataTable extends SignalWatcher(LitElement) {
289
298
  --rdt-page-size-radius: var(--rozie-data-table-radius, var(--rdt-ds-radius));
290
299
  }
291
300
  .rozie-data-table[data-rozie-s-d5dcab4c] {
292
- border-collapse: collapse;
301
+ /* SEPARATE, not collapse. With collapsed borders the browser keeps a sticky cell's shared
302
+ border in the scrolling grid, so a 1px line of scrolled content shows beside pinned
303
+ columns and above the sticky header. Separate borders travel with their cell; zero
304
+ spacing keeps lines 1px because each cell draws only its own sides (see the cell rule). */
305
+ border-collapse: separate;
306
+ border-spacing: 0;
293
307
  width: 100%;
294
308
  font: var(--rdt-font, 14px system-ui, sans-serif);
295
309
  color: var(--rdt-color, inherit);
@@ -348,8 +362,28 @@ export default class DataTable extends SignalWatcher(LitElement) {
348
362
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c] {
349
363
  padding: var(--rdt-cell-padding, 0.5rem 0.75rem);
350
364
  text-align: left;
365
+ /* Reset every side, then draw only the row line. Under separate borders each cell owns its
366
+ lines, and the reset keeps a host's plain \`table td { border }\` rule from drawing a second
367
+ set inside the table (this class-scoped rule outranks it). */
368
+ border: 0;
351
369
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
352
370
  }
371
+ .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-detail-cell[data-rozie-s-d5dcab4c] {
372
+ border: 0;
373
+ }
374
+ .rozie-data-table.rdt-column-lines[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c],
375
+ .rozie-data-table.rdt-column-lines[data-rozie-s-d5dcab4c] .rdt-td[data-rozie-s-d5dcab4c],
376
+ .rozie-data-table.rdt-column-lines[data-rozie-s-d5dcab4c] .rdt-filter-cell[data-rozie-s-d5dcab4c],
377
+ .rozie-data-table.rdt-column-lines[data-rozie-s-d5dcab4c] .rdt-detail-cell[data-rozie-s-d5dcab4c] {
378
+ border-inline-end: var(--rdt-column-border, 1px solid rgba(0, 0, 0, 0.08));
379
+ }
380
+ .rozie-data-table.rdt-column-lines[data-rozie-s-d5dcab4c] tr[data-rozie-s-d5dcab4c] > [data-rozie-s-d5dcab4c]:first-child:not(.rdt-col-spacer[data-rozie-s-d5dcab4c]),
381
+ .rozie-data-table.rdt-column-lines[data-rozie-s-d5dcab4c] tr[data-rozie-s-d5dcab4c] > .rdt-col-spacer[data-rozie-s-d5dcab4c]:first-child + *[data-rozie-s-d5dcab4c] {
382
+ border-inline-start: var(--rdt-column-border, 1px solid rgba(0, 0, 0, 0.08));
383
+ }
384
+ .rozie-data-table.rdt-column-lines[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] > tr[data-rozie-s-d5dcab4c]:first-child > .rdt-th[data-rozie-s-d5dcab4c] {
385
+ border-top: var(--rdt-column-border, 1px solid rgba(0, 0, 0, 0.08));
386
+ }
353
387
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-thead[data-rozie-s-d5dcab4c] .rdt-th[data-rozie-s-d5dcab4c] {
354
388
  font-weight: var(--rdt-header-weight, 600);
355
389
  /* OPAQUE default (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body
@@ -473,8 +507,21 @@ export default class DataTable extends SignalWatcher(LitElement) {
473
507
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-pinned[data-rozie-s-d5dcab4c] {
474
508
  box-sizing: border-box;
475
509
  }
510
+ .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-pinned[data-rozie-s-d5dcab4c] {
511
+ background-color: var(--rdt-pinned-bg, #fff);
512
+ }
513
+ .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-group-header[data-rozie-s-d5dcab4c] > .rdt-td.rdt-pinned[data-rozie-s-d5dcab4c] {
514
+ background: linear-gradient(var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025)), var(--rdt-group-header-bg, rgba(0, 0, 0, 0.025))), var(--rdt-pinned-bg, #fff);
515
+ }
516
+ .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-cell.rdt-pinned[data-rozie-s-d5dcab4c] {
517
+ background: linear-gradient(var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015)), var(--rdt-filter-row-bg, rgba(0, 0, 0, 0.015))), var(--rdt-pinned-bg, #fff);
518
+ }
519
+ .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-td.rdt-pinned.rdt-in-range[data-rozie-s-d5dcab4c] {
520
+ background: linear-gradient(var(--rdt-range-bg, rgba(37, 99, 235, 0.12)), var(--rdt-range-bg, rgba(37, 99, 235, 0.12))), var(--rdt-pinned-bg, #fff);
521
+ }
476
522
  .rozie-data-table[data-rozie-s-d5dcab4c] .rdt-filter-cell[data-rozie-s-d5dcab4c] {
477
523
  padding: var(--rdt-filter-cell-padding, 0.35rem 0.75rem);
524
+ border: 0;
478
525
  border-bottom: var(--rdt-border, 1px solid rgba(0, 0, 0, 0.08));
479
526
  }
480
527
  .rozie-data-table-wrap[data-rozie-s-d5dcab4c] .rdt-filter-row[data-rozie-s-d5dcab4c] .rdt-col-filter {
@@ -799,6 +846,10 @@ export default class DataTable extends SignalWatcher(LitElement) {
799
846
  * Pure-CSS sticky header: the `<thead>` sticks to the top of the scroll container.
800
847
  */
801
848
  @property({ type: Boolean, reflect: true, attribute: 'sticky-header' }) stickyHeader: boolean = false;
849
+ /**
850
+ * Draw a line between columns, plus an outer line at the start of each row and above the header row. The colour follows `--rozie-data-table-border-color` unless `--rozie-data-table-column-border-color` is set.
851
+ */
852
+ @property({ type: Boolean, reflect: true, attribute: 'column-lines' }) columnLines: boolean = false;
802
853
  /**
803
854
  * `'table'` (default, row-oriented, byte-behaviorally identical to a plain accessible table) | `'grid'` — lights up the full WAI-ARIA **[grid interaction mode](/components/data-table-grid-mode)**: `role="grid"`, a roving single tab-stop, 2-D APG arrow-key cell navigation, range selection, and clipboard support.
804
855
  */
@@ -816,7 +867,7 @@ export default class DataTable extends SignalWatcher(LitElement) {
816
867
  */
817
868
  @property({ type: Number, reflect: true, attribute: 'undo-limit' }) undoLimit: number = 100;
818
869
  /**
819
- * When `true`, the table holds its current view through its own writes (edit, paste, fill, cut, clear, the boolean toggle, undo and redo): rows keep the membership and order they had before the write and new values render in place, so an edited row does not jump or vanish under an active sort or filter. The view is re-derived (and both mismatch counts reset to 0) when `sorting`, `columnFilters` or `globalFilter` change, when `refreshView()` is called, when the dataset is swapped externally, or when `holdView` is toggled. Paging, page size, column visibility/order/pinning/sizing and expanding rows move through the held rows; a table write or `refreshView()` never changes the page (a sort, filter or grouping change returns to the first page). Falls back to live behaviour (with one console warning) while grouping is active, in `manual` mode, or with tree data (`getSubRows`). Passing a fresh `data` array that did not come from the table (for example reloading from the server after a save) is an external swap: it re-derives the view and clears undo history. Track mismatches with `view-state-change` / `getViewState()`.
870
+ * When `true`, the table holds its current view through its own writes (edit, paste, fill, range fill, cut, clear, the boolean toggle, undo and redo): rows keep the membership and order they had before the write and new values render in place, so an edited row does not jump or vanish under an active sort or filter. The view is re-derived (and both mismatch counts reset to 0) when `sorting`, `columnFilters` or `globalFilter` change, when `refreshView()` is called, when the dataset is swapped externally, or when `holdView` is toggled. Paging, page size, column visibility/order/pinning/sizing and expanding rows move through the held rows; a table write or `refreshView()` never changes the page (a sort, filter or grouping change returns to the first page). Falls back to live behaviour (with one console warning) while grouping is active, in `manual` mode, or with tree data (`getSubRows`). Passing a fresh `data` array that did not come from the table (for example reloading from the server after a save) is an external swap: it re-derives the view and clears undo history. Track mismatches with `view-state-change` / `getViewState()`.
820
871
  */
821
872
  @property({ type: Boolean, reflect: true, attribute: 'hold-view' }) holdView: boolean = false;
822
873
  /**
@@ -958,7 +1009,9 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
958
1009
  @property({ attribute: false }) cell?: (scope: { columnId: string; column: TableColumn<any, unknown>; row: any; value: any }) => unknown;
959
1010
  @state() private _hasSlotEditor = false;
960
1011
  @queryAssignedElements({ slot: 'editor', flatten: true }) private _slotEditorElements!: Element[];
961
- @property({ attribute: false }) editor?: (scope: { columnId: string; column: TableColumn<any, unknown>; row: any; value: any; commit: (value: any) => void; cancel: () => void; columnLabel: string; autofocus: boolean }) => unknown;
1012
+ @property({ attribute: false }) editor?: (scope: { columnId: string; column: TableColumn<any, unknown>; row: any; value: any; commit: (value: any, options?: {
1013
+ toRange?: boolean;
1014
+ }) => void; cancel: () => void; columnLabel: string; autofocus: boolean; rangeSize: number }) => unknown;
962
1015
  // Phase 79 Plan 08 (R4) contract for 79-09: the record intake for
963
1016
  // record-routed slot fills. 79-09's consumer-side emitSlotFiller
964
1017
  // accumulates an object literal onto the SAME `.rozieSlots=${{ ... }}`
@@ -968,7 +1021,9 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
968
1021
  // named function-prop / <slot> fallback (AC-9). Attribute
969
1022
  // deserialization is disabled — this is a function-valued record,
970
1023
  // never reflected to/from an HTML attribute.
971
- @property({ attribute: false }) rozieSlots?: { [key: `colHeader-${string}`]: (scope: { columnId: string; column: TableColumn<any, unknown>; label: string }) => unknown; [key: `filter-${string}`]: (scope: { columnId: string; value: any; uniqueValues: any[]; minMax: [number, number] | null; columnLabel: string; setFilter: (columnId: string, value: any) => void }) => unknown; [key: `cell-${string}`]: (scope: { columnId: string; column: TableColumn<any, unknown>; row: any; value: any }) => unknown; [key: `editor-${string}`]: (scope: { columnId: string; column: TableColumn<any, unknown>; row: any; value: any; commit: (value: any) => void; cancel: () => void; columnLabel: string; autofocus: boolean }) => unknown; } & Record<string, (scope: any) => unknown>;
1024
+ @property({ attribute: false }) rozieSlots?: { [key: `colHeader-${string}`]: (scope: { columnId: string; column: TableColumn<any, unknown>; label: string }) => unknown; [key: `filter-${string}`]: (scope: { columnId: string; value: any; uniqueValues: any[]; minMax: [number, number] | null; columnLabel: string; setFilter: (columnId: string, value: any) => void }) => unknown; [key: `cell-${string}`]: (scope: { columnId: string; column: TableColumn<any, unknown>; row: any; value: any }) => unknown; [key: `editor-${string}`]: (scope: { columnId: string; column: TableColumn<any, unknown>; row: any; value: any; commit: (value: any, options?: {
1025
+ toRange?: boolean;
1026
+ }) => void; cancel: () => void; columnLabel: string; autofocus: boolean; rangeSize: number }) => unknown; } & Record<string, (scope: any) => unknown>;
972
1027
 
973
1028
  private _disconnectCleanups: Array<() => void> = [];
974
1029
  // Re-parenting guard: set true once the deferred teardown has actually
@@ -1499,6 +1554,12 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1499
1554
  };
1500
1555
  document.addEventListener('pointerdown', this.docPointerDown, true);
1501
1556
  }
1557
+ // Range fill: a CAPTURE-phase keydown on the grid root runs before any editor's own keydown
1558
+ // handler — built-in, drop-in or slotted (on Lit the slotted editor's composed path still
1559
+ // passes through the grid root). Registered here in script rather than as a template
1560
+ // `.capture` binding so it is the same code on all six targets. It reads no reactive state
1561
+ // (a mount-time closure would see stale state on React); the commit checks the range.
1562
+ if (this.gridRoot) this.gridRoot.addEventListener('keydown', this.onEditorDropinKeyDownCapture, true);
1502
1563
  // WR-04: NO on-mount auto-focus of the entry cell. Auto-focusing here stole focus on
1503
1564
  // page load AND was non-deterministic on React/Solid (the entry cell may not be
1504
1565
  // committed to the DOM yet at the $onMount microtask). The roving tabindex="0" entry
@@ -1693,6 +1754,7 @@ private __rozieCtxProvider_data_table_columns = new ContextProvider(this, { cont
1693
1754
  document.removeEventListener('pointerdown', this.docPointerDown, true);
1694
1755
  this.docPointerDown = null;
1695
1756
  }
1757
+ if (this.gridRoot) this.gridRoot.removeEventListener('keydown', this.onEditorDropinKeyDownCapture, true);
1696
1758
  if (this.virtualizerCleanup) this.virtualizerCleanup();
1697
1759
  // Phase 87 87-04: tear down the column virtualizer's scroll-element ResizeObserver too.
1698
1760
  // No-op when column windowing was off (cleanup stays null).
@@ -1757,7 +1819,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1757
1819
  ${repeat<any>(this.groupingKeys(), (gk, _idx) => gk, (gk, _idx) => html`<span class="rdt-group-token" data-group-token="" data-rozie-s-d5dcab4c>${rozieDisplay(gk)}</span>`)}
1758
1820
  </slot>`}
1759
1821
  </div>` : nothing}${this.isWindowed() ? html`<div class="rdt-scroll" style=${rozieStyle(this.rowsWindowed() && this.maxHeight ? 'max-height:' + this.maxHeight + ';overflow:auto;--rozie-data-table-max-height:' + this.maxHeight : 'overflow:auto')} data-rozie-s-d5dcab4c>
1760
- <table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader, 'rdt-col-windowed': this.colsWindowed() }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} aria-colcount=${rozieAttr(this.gridAriaColCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
1822
+ <table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader, 'rdt-col-windowed': this.colsWindowed(), 'rdt-column-lines': this.columnLines }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} aria-colcount=${rozieAttr(this.gridAriaColCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
1761
1823
  <thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>
1762
1824
  ${repeat<any>(this._headerGroups.value, (hg, hgLevel) => hg.id, (hg, hgLevel) => html`<tr class="rdt-tr" role="row" aria-rowindex=${rozieAttr(hgLevel + 1)} data-rozie-s-d5dcab4c>
1763
1825
 
@@ -1839,8 +1901,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1839
1901
  ${this.editorTypeOf(cell.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cell.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} .value=${this.editorValueFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
1840
1902
  ${repeat<any>(this.editorOptionsOf(cell.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option value=${rozieAttr(opt.value)} ?selected=${opt.value === this.editorValueFor(cell.column.id)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
1841
1903
  </select>` : this.editorTypeOf(cell.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} ?checked=${this.editorCheckedFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorCheckbox(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cell.column.id) === 'custom' ? html`<span style="display:contents" @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onEditorDropinKeyDown($event); }} data-rozie-s-d5dcab4c>
1842
- ${this.rozieSlots?.[`editor-${cell.column.id}`] !== undefined ? this.rozieSlots?.[`editor-${cell.column.id}`]!({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index)}) : html`<slot name="${`editor-${cell.column.id}`}" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index)}); } catch { return '{}'; } })()}>
1843
- ${this.editor !== undefined ? this.editor({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index)}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index)}); } catch { return '{}'; } })()}>
1904
+ ${this.rozieSlots?.[`editor-${cell.column.id}`] !== undefined ? this.rozieSlots?.[`editor-${cell.column.id}`]!({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}) : html`<slot name="${`editor-${cell.column.id}`}" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}); } catch { return '{}'; } })()}>
1905
+ ${this.editor !== undefined ? this.editor({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(wr.row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, wr.vi.index), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}); } catch { return '{}'; } })()}>
1844
1906
  <input class="rdt-cell-editor" type="text" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />
1845
1907
  </slot>`}
1846
1908
  </slot>`}
@@ -1863,7 +1925,7 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1863
1925
  </tr>
1864
1926
  </tbody>
1865
1927
  </table>
1866
- </div>` : html`<table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} aria-colcount=${rozieAttr(this.gridAriaColCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
1928
+ </div>` : html`<table class="${Object.entries({ "rozie-data-table": true, 'rdt-sticky': this.stickyHeader, 'rdt-column-lines': this.columnLines }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role=${rozieAttr(this.tableRole())} aria-rowcount=${rozieAttr(this.gridAriaRowCount())} aria-colcount=${rozieAttr(this.gridAriaColCount())} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridKeyDown($event); }} @focusin=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.syncActiveFromEvent($event); }} @focusout=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridFocusOut($event); }} @mousedown=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridMouseDown($event); }} @dblclick=${($event: Event & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridDblClick($event); }} @click=${($event: MouseEvent & { currentTarget: HTMLTableElement; target: HTMLTableElement }) => { this.onGridClick($event); }} data-rozie-s-d5dcab4c>
1867
1929
  <thead class="rdt-thead" role="rowgroup" data-rozie-s-d5dcab4c>
1868
1930
  ${repeat<any>(this._headerGroups.value, (hg, hgLevel) => hg.id, (hg, hgLevel) => html`<tr class="rdt-tr" role="row" aria-rowindex=${rozieAttr(hgLevel + 1)} data-rozie-s-d5dcab4c>
1869
1931
  ${repeat<any>(hg.headers, (header, _idx) => header.id, (header, _idx) => html`<th class="${Object.entries({ "rdt-th": true, 'rdt-select-th': this.isSelectColumn(header.column.id), 'rdt-expander-th': this.isExpanderColumn(header.column.id), 'rdt-pinned': this.columnPinSide(header.column.id) !== '', 'rdt-th-resizing': this.columnIsResizing(header.column.id), 'rdt-cell-active': this.isActiveCell('__header', this.headerColIndexOf(hg, header), hgLevel) }).filter(([, v]) => v).map(([k]) => k).join(' ')}" role="columnheader" data-col=${rozieAttr(header.column.id)} data-grid-cell="" data-row="__header" data-header-level=${rozieAttr(hgLevel)} colspan=${rozieAttr(header.colSpan > 1 ? header.colSpan : null)} data-col-index=${rozieAttr(this.headerColIndexOf(hg, header))} aria-colindex=${rozieAttr(this.headerLeafStart(hg, header) + 1)} tabindex=${rozieAttr(this.cellTabindex('__header', this.headerColIndexOf(hg, header), hgLevel))} aria-sort=${rozieAttr(this.ariaSortFor(header.column.id))} style=${rozieStyle(this.thStyle(header))} data-rozie-s-d5dcab4c>
@@ -1936,8 +1998,8 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
1936
1998
  ${this.editorTypeOf(cell.column.id) === 'number' ? html`<input class="rdt-cell-editor" type="number" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cell.column.id) === 'select' ? html`<select class="rdt-cell-editor" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} .value=${this.editorValueFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c>
1937
1999
  ${repeat<any>(this.editorOptionsOf(cell.column.id), (opt, _idx) => opt.value, (opt, _idx) => html`<option value=${rozieAttr(opt.value)} ?selected=${opt.value === this.editorValueFor(cell.column.id)} data-rozie-s-d5dcab4c>${rozieDisplay(opt.label)}</option>`)}
1938
2000
  </select>` : this.editorTypeOf(cell.column.id) === 'checkbox' ? html`<input class="rdt-cell-editor" type="checkbox" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} ?checked=${this.editorCheckedFor(cell.column.id)} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorCheckbox(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />` : this.editorTypeOf(cell.column.id) === 'custom' ? html`<span style="display:contents" @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onEditorDropinKeyDown($event); }} data-rozie-s-d5dcab4c>
1939
- ${this.rozieSlots?.[`editor-${cell.column.id}`] !== undefined ? this.rozieSlots?.[`editor-${cell.column.id}`]!({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row))}) : html`<slot name="${`editor-${cell.column.id}`}" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row))}); } catch { return '{}'; } })()}>
1940
- ${this.editor !== undefined ? this.editor({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row))}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row))}); } catch { return '{}'; } })()}>
2001
+ ${this.rozieSlots?.[`editor-${cell.column.id}`] !== undefined ? this.rozieSlots?.[`editor-${cell.column.id}`]!({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row)), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}) : html`<slot name="${`editor-${cell.column.id}`}" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row)), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}); } catch { return '{}'; } })()}>
2002
+ ${this.editor !== undefined ? this.editor({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row)), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}) : html`<slot name="editor" data-rozie-params=${(() => { try { return JSON.stringify({columnId: cell.column.id, column: cell.column, row: this.cellSlotRow(row), value: this.editorValueFor(cell.column.id), commit: this.editorCommitFor(cell.column.id), cancel: this.editorCancelFor(), columnLabel: this.headerLabel(cell.column.id), autofocus: this.editorAutofocusFor(cell.column.id, this.rowIndexOf(row)), rangeSize: this.inRowEdit() ? 1 : this.rangeSize()}); } catch { return '{}'; } })()}>
1941
2003
  <input class="rdt-cell-editor" type="text" data-editing-cell="" data-builtin-editor="" aria-invalid=${rozieAttr(this._invalidMsg.value ? 'true' : null)} .value=${this.editorValueFor(cell.column.id)} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onCellEditorInput(cell.column.id, $event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorKeyDown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onEditorBlur($event); }} data-rozie-s-d5dcab4c />
1942
2004
  </slot>`}
1943
2005
  </slot>`}
@@ -6571,6 +6633,10 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
6571
6633
  // any OTHER cell falls through to clearRange() and clears the flag on the way past.
6572
6634
  if (this.rangeTransition) {
6573
6635
  this.rangeTransition = false;
6636
+ } else if (!isHeader && tgt !== cellEl && this._editingRow.value === movedRow && this._editingCol.value === col) {
6637
+ // Focus moving INTO the open editor of this cell (a built-in input, or a slot editor that
6638
+ // focuses itself) is not navigation: keep the range so the editor can commit to it with
6639
+ // Ctrl/Cmd+Enter.
6574
6640
  } else if (this.rangeClickPending && this.rangeClickPending.isHeader === isHeader && this.rangeClickPending.r === movedRow && this.rangeClickPending.c === col) {
6575
6641
  this.rangeClickPending = null;
6576
6642
  } else {
@@ -7963,6 +8029,25 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
7963
8029
  this.applyGridToRange(grid, box.r0, box.c0, 'filled');
7964
8030
  };
7965
8031
 
8032
+ // ══ Range fill (Ctrl/Cmd+Enter from an editor; Space on a checkbox range) ════════════════
8033
+ // rangeSize(): how many cells a range fill would write — the range's rows in the ACTIVE
8034
+ // cell's column, or 1 with no range. Exposed to editor slots as `rangeSize` so a custom
8035
+ // editor can say "Apply to 24 cells".
8036
+ fillTargets = () => rangeFillTargets(this.normalizedRange(), this._activeRow.value, this._activeColIndex.value);
8037
+
8038
+ rangeSize = () => this._activeIsHeader.value ? 1 : this.fillTargets().size;
8039
+
8040
+ // fillActiveColumn(value): write ONE value down the active cell's column for every row of the
8041
+ // range, through the same funnel as paste and fill (skip rule, one undo step, one
8042
+ // cell-edit-commit per changed cell, "N of M cells updated"). The value is written AS IS — a
8043
+ // one-column grid of the same value, never stringified — so an array or object survives.
8044
+ fillActiveColumn = (value: any) => {
8045
+ const t = this.fillTargets();
8046
+ const grid = [];
8047
+ for (let i = 0; i < t.size; i++) grid.push([value]);
8048
+ return this.applyGridToRange(grid, t.top, t.col, 'updated');
8049
+ };
8050
+
7966
8051
  // onFillHandlePointerDown: begin a fill-handle drag (req-8 / D-04). The handle sits on the
7967
8052
  // range's bottom-right cell; a pointer drag extends the range (reusing setRangeFocus off the
7968
8053
  // cell under the pointer) and, on release, value-fills the dragged rectangle. Kept minimal:
@@ -8805,6 +8890,62 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
8805
8890
  return true;
8806
8891
  };
8807
8892
 
8893
+ // commitEditToRange(overrideValue): the Ctrl/Cmd+Enter commit. Writes the editor's value to
8894
+ // every selected cell in the editing cell's column through fillActiveColumn (the paste/fill
8895
+ // funnel: skip rule, one undo step, one cell-edit-commit per changed cell, "N of M cells
8896
+ // updated"), then closes the editor and returns focus to the active cell WITHOUT clearing the
8897
+ // range. The value is checked against the editing cell first: if the cell being edited rejects
8898
+ // it, the editor stays open with the error, exactly as a single-cell commit does.
8899
+ commitEditToRange = (overrideValue = undefined) => {
8900
+ if (this._editingRow.value < 0) return false;
8901
+ if (this.committedThisSession) return false;
8902
+ const colId = this.editingColumnId();
8903
+ if (colId == null) {
8904
+ this.endEdit();
8905
+ return false;
8906
+ }
8907
+ const rawValue = overrideValue !== undefined ? overrideValue : this._draftValue.value;
8908
+ const err = this.runValidator(colId, this.coerceCellValue(colId, rawValue), this.editingRowOriginal());
8909
+ if (err !== true) {
8910
+ this.setInvalid(err);
8911
+ this.focusEditorWhenReady(this._editingRow.value, this._editingCol.value);
8912
+ return false;
8913
+ }
8914
+ this.setInvalid('');
8915
+ const focusRow = this._editingRow.value;
8916
+ const focusCol = this._editingCol.value;
8917
+ const rowOriginal = this.editingRowOriginal();
8918
+ const rowId = this.editingRowId();
8919
+ // Same teardown guards as commitEdit: the unmount blur of the editor must not commit again.
8920
+ this.editTransition = true;
8921
+ this.committedThisSession = true;
8922
+ const result = this.fillActiveColumn(rawValue);
8923
+ this.endEdit();
8924
+ this.editTransition = false;
8925
+ // Keep the range when focus comes back to the active cell (the selection stays put after a
8926
+ // fill, as in a spreadsheet). Coordinate-matched, like a Shift+Click: if focus lands anywhere
8927
+ // else first (the user clicks another cell, or the focus return gives up), that focusin
8928
+ // clears the range as usual instead of inheriting a stale "keep" flag.
8929
+ this.rangeClickPending = {
8930
+ isHeader: false,
8931
+ r: focusRow,
8932
+ c: focusCol
8933
+ };
8934
+ if (result && result.changed > 0) {
8935
+ // A write can re-sort or re-filter the rows (holdView off), so follow the active cell's
8936
+ // RECORD rather than its old index — the same B23 path commitEdit uses. A re-order also
8937
+ // clears the range, so the coordinate match above only keeps it when nothing moved.
8938
+ this.pendingEditFollow = {
8939
+ rowOriginal,
8940
+ rowId,
8941
+ col: focusCol
8942
+ };
8943
+ } else {
8944
+ this.focusCellWhenReady(focusRow, focusCol);
8945
+ }
8946
+ return true;
8947
+ };
8948
+
8808
8949
  // toggleActiveBooleanCell (design doc 2026-07-05, Change 1): the spreadsheet-standard
8809
8950
  // single-keystroke boolean toggle. Flips the ACTIVE cell's value and commits it through the
8810
8951
  // EXACT SAME write funnel commitEdit uses (replaceRowValue → writeData → single $emit) but
@@ -8833,6 +8974,14 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
8833
8974
  const rowId = row.id;
8834
8975
  const oldValue = this.cellValueAt(this._activeRow.value, this._activeColIndex.value);
8835
8976
  const newValue = !oldValue;
8977
+ // With a range selected, set every selected cell in this column to the active cell's new
8978
+ // value (one value for all, not a per-cell flip) through the paste/fill funnel: invalid cells
8979
+ // skipped, one undo step, one cell-edit-commit per changed cell, "N of M cells updated".
8980
+ if (this.rangeSize() > 1) {
8981
+ this.setInvalid('');
8982
+ this.fillActiveColumn(newValue);
8983
+ return;
8984
+ }
8836
8985
  // D-01: same discipline as commitEdit — a rejecting validator blocks the toggle. There is
8837
8986
  // no editor to keep open here, so the toggle simply does not apply (no model write).
8838
8987
  const err = this.runValidator(colId, newValue, rowOriginal);
@@ -9238,6 +9387,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
9238
9387
  committed: boolean;
9239
9388
  } | null = null;
9240
9389
 
9390
+ // rangeCommitIntent: set by the drop-in wrapper's CAPTURE-phase keydown when Ctrl/Cmd+Enter is
9391
+ // pressed while a range is selected, so the slot editor's own commit — which runs during that
9392
+ // same keypress — goes to the whole range. Cleared by the bubble-phase handler and by a
9393
+ // setTimeout(0), so an editor that does not commit during the keypress falls back to one cell.
9394
+ rangeCommitIntent = false;
9395
+
9241
9396
  // ── Per-cell editor draft source (req-6) ──────────────────────────────────────────────
9242
9397
  // In single-cell mode every editor binds the shared $data.draftValue. In full-row mode
9243
9398
  // (editingRowIndex != null) each editable cell owns its OWN draft keyed by columnId in
@@ -9256,11 +9411,19 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
9256
9411
  // only WRITES this column's draft (the row commits as a unit later — never per cell). cancel()
9257
9412
  // reverts the cell (single) or the whole row (row mode). Factory-bound per columnId so the
9258
9413
  // row-mode commit targets the right draft key.
9259
- editorCommitFor = (colId: any) => (value: any) => {
9414
+ editorCommitFor = (colId: any) => (value: any, options: any) => {
9260
9415
  if (this.inRowEdit()) {
9261
9416
  this.setRowDraft(colId, value);
9262
9417
  return;
9263
9418
  }
9419
+ // Range fill: an explicit `commit(v, { toRange: true })`, or any commit made during a
9420
+ // Ctrl/Cmd+Enter keypress (rangeCommitIntent), writes v to every selected cell in this column.
9421
+ const wantsRange = options != null && options.toRange === true || this.rangeCommitIntent;
9422
+ this.rangeCommitIntent = false;
9423
+ if (wantsRange && !this.dropinTabFlush && this.rangeSize() > 1) {
9424
+ this.commitEditToRange(value);
9425
+ return;
9426
+ }
9264
9427
  // C-10: during the drop-in wrapper's Tab flush, record the outcome and skip the focus
9265
9428
  // return — the Tab handler advances focus to the next editor itself.
9266
9429
  if (this.dropinTabFlush) {
@@ -9372,7 +9535,12 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
9372
9535
  }
9373
9536
  return;
9374
9537
  }
9375
- if (key === 'Enter') {
9538
+ if (key === 'Enter' && (e.ctrlKey || e.metaKey) && this.rangeSize() > 1) {
9539
+ // Ctrl/Cmd+Enter with a range selected: write the draft to every selected cell in this
9540
+ // column. Plain Enter (below) still commits the active cell only.
9541
+ e.preventDefault();
9542
+ this.commitEditToRange(undefined);
9543
+ } else if (key === 'Enter') {
9376
9544
  e.preventDefault();
9377
9545
  this.commitEdit(undefined);
9378
9546
  } else if (key === 'Tab') {
@@ -9460,8 +9628,24 @@ ${this.groupable ? html`<div class="rdt-group-bar-host" data-rozie-s-d5dcab4c>
9460
9628
  // advances. `dropinTabFlush` tells editorCommitFor to report the outcome and skip the focus
9461
9629
  // return. A drop-in that does not commit on blur (EditorCheckbox commits on change, so it holds
9462
9630
  // nothing pending) falls back to the host-draft path, which is exactly right in that case.
9631
+ // Capture-phase keydown on the grid root (registered at mount): runs BEFORE any editor's own
9632
+ // keydown handler. Ctrl/Cmd+Enter marks the commit an editor makes during this keypress as a
9633
+ // range commit; editorCommitFor decides whether a range is actually selected. The mark expires
9634
+ // after this event, so a commit that comes later (on blur, after an async step) writes one
9635
+ // cell, as usual. Reads NO reactive state: it is a mount-time closure (stale on React).
9636
+ onEditorDropinKeyDownCapture = (e: any) => {
9637
+ if (!e) return;
9638
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
9639
+ this.rangeCommitIntent = true;
9640
+ setTimeout(() => {
9641
+ this.rangeCommitIntent = false;
9642
+ }, 0);
9643
+ }
9644
+ };
9645
+
9463
9646
  onEditorDropinKeyDown = (e: any) => {
9464
9647
  if (!e) return;
9648
+ this.rangeCommitIntent = false;
9465
9649
  const key = e.key;
9466
9650
  if (this.inRowEdit()) {
9467
9651
  if (key === 'Enter') {
@@ -0,0 +1,22 @@
1
+ // rangeFill.ts — which cells a range fill writes. Pure, sigil-free.
2
+ //
3
+ // A range fill (Ctrl/Cmd+Enter in an editor, or Space on a checkbox range) writes one value
4
+ // down the ACTIVE cell's column for every row the range covers. Other columns of a
5
+ // multi-column range are left alone: one typed value rarely makes sense in several columns.
6
+ // With no range, or an active cell outside it, the fill is the active cell alone (size 1).
7
+
8
+ export interface FillRange { r0: number; r1: number; c0: number; c1: number }
9
+ export interface FillTargets { top: number; bottom: number; col: number; size: number }
10
+
11
+ const rangeFillTargets = (range: FillRange | null, activeRow: number, activeCol: number): FillTargets => {
12
+ const single = { top: activeRow, bottom: activeRow, col: activeCol, size: 1 }
13
+ if (!range) return single
14
+ const top = Math.min(range.r0, range.r1)
15
+ const bottom = Math.max(range.r0, range.r1)
16
+ const left = Math.min(range.c0, range.c1)
17
+ const right = Math.max(range.c0, range.c1)
18
+ if (activeCol < left || activeCol > right || activeRow < top || activeRow > bottom) return single
19
+ return { top, bottom, col: activeCol, size: bottom - top + 1 }
20
+ }
21
+
22
+ export { rangeFillTargets }
@@ -1,15 +1,15 @@
1
1
  /*
2
2
  * @rozie-ui/data-table — base token reference.
3
3
  *
4
- * This file is the PUBLIC token reference. It wires each of the 18 public
4
+ * This file is the PUBLIC token reference. It wires each of the 20 public
5
5
  * `--rozie-data-table-*` tokens, with its default, onto the short internal `--rdt-*`
6
- * token the component's scoped `<style>` actually reads. The component reads 82 internal `--rdt-*`
6
+ * token the component's scoped `<style>` actually reads. The component reads 84 internal `--rdt-*`
7
7
  * tokens in total, each with an inline `var(--rdt-x, <fallback>)` default, so the
8
8
  * table renders ZERO-CONFIG without importing this file.
9
9
  *
10
10
  * Scope, stated plainly: remapping the public tokens re-skins the table's primary
11
11
  * surfaces — type, borders, background, header, row, cell padding, accent, and the
12
- * max-height. It does NOT reach every cosmetic value. 58 of the 82 internal tokens
12
+ * max-height. It does NOT reach every cosmetic value. 58 of the 84 internal tokens
13
13
  * have no public counterpart today, including the grid active-cell ring, the range
14
14
  * highlight, the fill handle, the invalid-cell outline, the group tokens, the filter
15
15
  * row, the per-header column ⋯ menu (its panel is a composed @rozie-ui/popover, so its
@@ -27,7 +27,7 @@
27
27
  * the element itself), never on the `.rozie-data-table` / `.rozie-data-table-wrap`
28
28
  * classes — on that target both of those render inside the component's shadow root,
29
29
  * where a document stylesheet cannot reach them. The tokens themselves inherit across
30
- * the boundary, and the component carries the mapping internally, so every one of the 18
30
+ * the boundary, and the component carries the mapping internally, so every one of the 20
31
31
  * works there.
32
32
  *
33
33
  * NOTE the split: the STRUCTURAL rules — `border-collapse`, the keyed-r-for
@@ -75,6 +75,12 @@
75
75
  --rdt-border: 1px solid var(--rozie-data-table-border-color, rgba(0, 0, 0, 0.08));
76
76
  --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color, rgba(0, 0, 0, 0.2));
77
77
  --rdt-filter-radius: var(--rozie-data-table-radius, 4px);
78
+ /* Column lines are off unless the `columnLines` prop is set. Their colour follows the
79
+ row-line colour unless the column token is set, so one variable themes both. */
80
+ --rdt-column-border: 1px solid var(--rozie-data-table-column-border-color, var(--rozie-data-table-border-color, rgba(0, 0, 0, 0.08)));
81
+ /* Pinned body cells are opaque so scrolled content never shows through them. Set this
82
+ to the surface colour behind the table. */
83
+ --rdt-pinned-bg: var(--rozie-data-table-pinned-bg, #fff);
78
84
 
79
85
  /* ── typography / cell metrics ──────────────────────────────────────────── */
80
86
  --rdt-font: var(--rozie-data-table-font, 14px system-ui, sans-serif);
@@ -125,6 +131,8 @@
125
131
  --rdt-ds-fg: currentColor;
126
132
  --rdt-ds-select-accent: #0066cc;
127
133
  --rdt-ds-border-color: rgba(0, 0, 0, 0.08);
134
+ --rdt-ds-column-border-color: var(--rdt-ds-border-color);
135
+ --rdt-ds-pinned-bg: #fff;
128
136
  --rdt-ds-control-border-color: rgba(0, 0, 0, 0.2);
129
137
  --rdt-ds-radius: 4px;
130
138
  --rdt-ds-font: 14px system-ui, sans-serif;
@@ -35,6 +35,8 @@ rozie-data-table {
35
35
  --rdt-border: 1px solid var(--rozie-data-table-border-color, var(--bs-border-color, #dee2e6));
36
36
  --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color, var(--bs-border-color, #dee2e6));
37
37
  --rdt-filter-radius: var(--rozie-data-table-radius, var(--bs-border-radius, 0.375rem));
38
+ --rdt-column-border: 1px solid var(--rozie-data-table-column-border-color, var(--rozie-data-table-border-color, var(--bs-border-color, #dee2e6)));
39
+ --rdt-pinned-bg: var(--rozie-data-table-pinned-bg, var(--bs-body-bg, #fff));
38
40
  --rdt-header-bg: var(--rozie-data-table-header-bg, var(--bs-tertiary-bg, #f8f9fa));
39
41
  --rdt-resize-grip-color: var(--rozie-data-table-resize-grip-color, var(--bs-border-color, #dee2e6));
40
42
  --rdt-resize-grip-active: var(--rozie-data-table-resize-grip-active, var(--rozie-data-table-accent, var(--bs-primary, #0d6efd)));
@@ -60,6 +62,7 @@ rozie-data-table {
60
62
  --rdt-ds-fg: var(--bs-body-color, #212529);
61
63
  --rdt-ds-select-accent: var(--bs-primary, #0d6efd);
62
64
  --rdt-ds-border-color: var(--bs-border-color, #dee2e6);
65
+ --rdt-ds-pinned-bg: var(--bs-body-bg, #fff);
63
66
  --rdt-ds-control-border-color: var(--bs-border-color, #dee2e6);
64
67
  --rdt-ds-radius: var(--bs-border-radius, 0.375rem);
65
68
  --rdt-ds-header-bg: var(--bs-tertiary-bg, #f8f9fa);
@@ -33,6 +33,8 @@ rozie-data-table {
33
33
  --rdt-color: var(--rozie-data-table-fg, var(--md-sys-color-on-surface, #1d1b20));
34
34
  --rdt-select-accent: var(--rozie-data-table-select-accent, var(--rozie-data-table-accent, var(--md-sys-color-primary, #6750a4)));
35
35
  --rdt-border: 1px solid var(--rozie-data-table-border-color, var(--md-sys-color-outline-variant, #cac4d0));
36
+ --rdt-column-border: 1px solid var(--rozie-data-table-column-border-color, var(--rozie-data-table-border-color, var(--md-sys-color-outline-variant, #cac4d0)));
37
+ --rdt-pinned-bg: var(--rozie-data-table-pinned-bg, var(--md-sys-color-surface, #fef7ff));
36
38
  --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color, var(--md-sys-color-outline, #79747e));
37
39
  --rdt-header-bg: var(--rozie-data-table-header-bg, var(--md-sys-color-surface-container-highest, #e6e0e9));
38
40
  --rdt-header-weight: var(--rozie-data-table-header-weight, 500);
@@ -55,6 +57,7 @@ rozie-data-table {
55
57
  --rdt-ds-fg: var(--md-sys-color-on-surface, #1d1b20);
56
58
  --rdt-ds-select-accent: var(--md-sys-color-primary, #6750a4);
57
59
  --rdt-ds-border-color: var(--md-sys-color-outline-variant, #cac4d0);
60
+ --rdt-ds-pinned-bg: var(--md-sys-color-surface, #fef7ff);
58
61
  --rdt-ds-control-border-color: var(--md-sys-color-outline, #79747e);
59
62
  --rdt-ds-radius: 4px;
60
63
  --rdt-ds-header-bg: var(--md-sys-color-surface-container-highest, #e6e0e9);
@@ -39,6 +39,8 @@ rozie-data-table {
39
39
  --rdt-color: var(--rozie-data-table-fg, hsl(var(--foreground, 222.2 47.4% 11.2%)));
40
40
  --rdt-select-accent: var(--rozie-data-table-select-accent, var(--rozie-data-table-accent, hsl(var(--primary, 222.2 47.4% 11.2%))));
41
41
  --rdt-border: 1px solid var(--rozie-data-table-border-color, hsl(var(--border, 214.3 31.8% 91.4%)));
42
+ --rdt-column-border: 1px solid var(--rozie-data-table-column-border-color, var(--rozie-data-table-border-color, hsl(var(--border, 214.3 31.8% 91.4%))));
43
+ --rdt-pinned-bg: var(--rozie-data-table-pinned-bg, hsl(var(--background, 0 0% 100%)));
42
44
  --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color, hsl(var(--border, 214.3 31.8% 91.4%)));
43
45
  --rdt-filter-radius: var(--rozie-data-table-radius, var(--radius, 0.5rem));
44
46
  --rdt-header-bg: var(--rozie-data-table-header-bg, hsl(var(--muted, 210 40% 96.1%)));
@@ -65,6 +67,7 @@ rozie-data-table {
65
67
  --rdt-ds-fg: hsl(var(--foreground, 222.2 47.4% 11.2%));
66
68
  --rdt-ds-select-accent: hsl(var(--primary, 222.2 47.4% 11.2%));
67
69
  --rdt-ds-border-color: hsl(var(--border, 214.3 31.8% 91.4%));
70
+ --rdt-ds-pinned-bg: hsl(var(--background, 0 0% 100%));
68
71
  --rdt-ds-control-border-color: hsl(var(--border, 214.3 31.8% 91.4%));
69
72
  --rdt-ds-radius: var(--radius, 0.5rem);
70
73
  --rdt-ds-header-bg: hsl(var(--muted, 210 40% 96.1%));