@iyulab/flex-table 0.49.0 → 0.50.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.50.0] - 2026-10-06
4
+
5
+ ### Fixed
6
+
7
+ - **Tab no longer traps the focus in the table.** Tab past the last cell (Shift+Tab before the first)
8
+ stayed on that cell, so keyboard focus could never leave the table (WCAG 2.1.2). Tab still walks the
9
+ cells; at either end it now moves on to the next (previous) control on the page.
10
+
11
+ ### Added
12
+
13
+ - **The header row is reachable by keyboard.** ArrowUp from the first row moves onto the column's header
14
+ cell; ArrowLeft / ArrowRight / Home / End move along the header and ArrowDown returns to the body. On a
15
+ header cell, Enter or Space sorts (Shift adds the column to the sort), Alt+ArrowDown or the context-menu
16
+ key opens the column menu, and Alt+ArrowLeft / Alt+ArrowRight resize the column. Escape from that menu
17
+ returns to the same header cell. An empty table keeps the header row reachable.
18
+ - Keyboard focus arriving on a table with nothing active activates the first cell, so the first arrow
19
+ key already moves and no Tab is spent entering.
20
+
21
+ ### Changed
22
+
23
+ - **The column menu buttons are no longer Tab stops** (`tabindex="-1"`, like the row checkboxes) — the
24
+ table is one Tab stop and the header row reaches each column's menu. Pages that relied on tabbing to a
25
+ menu button reach it from the header row instead.
26
+
27
+ ## [0.49.1] - 2026-10-06
28
+
29
+ ### Fixed
30
+
31
+ - **The column filter is a dialog with a keyboard contract.** It is announced as a dialog named for its
32
+ column ("Filter Customer"); Tab and Shift+Tab stay inside it (it floats over the table, so focus that
33
+ left it would leave it open behind the next control); Escape closes it and returns focus to that
34
+ column's menu button — before, focus dropped to the page. A click outside still closes it without
35
+ moving focus.
36
+
3
37
  ## [0.49.0] - 2026-10-06
4
38
 
5
39
  ### Added
package/README.md CHANGED
@@ -437,8 +437,8 @@ package dependency; CSS custom properties are resolved at render time, not impor
437
437
 
438
438
  | Key | Action |
439
439
  |-----|--------|
440
- | Arrow keys | Navigate cells |
441
- | Tab / Shift+Tab | Move to next/previous cell |
440
+ | Arrow keys | Navigate cells (ArrowUp from the first row moves onto the header row) |
441
+ | Tab / Shift+Tab | Move to next/previous cell; past the last (before the first) cell, leave the table |
442
442
  | Enter / F2 | Start editing (Enter on a non-editable cell fires `row-activate`) |
443
443
  | Typing a printable character | Start editing an editable cell with that character |
444
444
  | Escape | Cancel edit / clear selection / close the find panel |
@@ -455,9 +455,15 @@ package dependency; CSS custom properties are resolved at render time, not impor
455
455
  | Ctrl+Shift+Z / Ctrl+Y | Redo |
456
456
  | Alt+ArrowLeft / Alt+ArrowRight | Resize current column (±20px) |
457
457
  | Ctrl+Click header | Select entire column |
458
+ | ArrowLeft / ArrowRight / Home / End (header row) | Move along the header; ArrowDown returns to the first row |
459
+ | Enter / Space (header row) | Sort the column (ascending → descending → none); with Shift, add it to the sort |
460
+ | Alt+ArrowDown or the context-menu key (header row) | Open the column menu |
458
461
  | Enter / Space on a column menu button | Open the column menu |
459
462
  | ArrowUp / ArrowDown / Home / End (column menu) | Move between menu items |
460
- | Escape (column menu) | Close the menu and return focus to its button |
463
+ | Escape (column menu) | Close the menu and return focus to where it was opened from |
464
+
465
+ The table is one Tab stop: keyboard focus arriving on it activates the first cell (the first header cell
466
+ when there are no rows), and the column menu buttons and row checkboxes are reached by keys, not Tab.
461
467
 
462
468
  ## Localization
463
469
 
@@ -519,6 +525,7 @@ conformance claim for the success criteria it does not list.
519
525
  |---|---|---|
520
526
  | SC 2.5.8 Target Size (Minimum) | Sortable headers, column menu buttons, the open column menu, the open filter dropdown (text and number), the find/replace bar, the cell context menu and row selection checkboxes are at least 24×24 CSS px or meet the spacing exception (24px between centers, counting the neighbouring resize handles), and are actually hit at that position. Two small targets use the equivalent-control exception: the 6px column resize handle (every resize is also in the column menu) and the hidden-column marker (its column menu offers **Show: …**) | `tests/browser/target-size.browser.test.ts` (real Chromium) |
521
527
  | SC 2.1.1 Keyboard (pointer-cursor check) | Nothing the grid renders shows a pointer cursor without being an interactive element. Sorting by header click also has a keyboard path — the column menu's **Sort ascending** / **Sort descending** (the check alone cannot see that, because the header cell wraps the menu button) | `tests/browser/target-size.browser.test.ts` · `src/flex-table.test.ts` |
528
+ | SC 2.4.3 Focus Order · SC 2.1.2 No Keyboard Trap | The column filter is a dialog named for its column: opening it from the column menu moves focus into it, Tab and Shift+Tab cycle inside it, and Escape closes it and returns focus to that column's menu button (Escape is the way out) | `tests/browser/column-menu.browser.test.ts` |
522
529
  | SC 2.5.7 Dragging Movements | Resizing never requires a drag — the column menu's **Auto-fit width**, **Wider** and **Narrower** are single clicks | `tests/browser/column-menu.browser.test.ts` |
523
530
  | SC 1.3.1 Info and Relationships (virtualized grid) | Only the visible rows and columns are in the DOM, so every row carries `aria-rowindex` and every cell `aria-colindex` (1-based, in display order), and the grid's `aria-rowcount` / `aria-colcount` give the full size — the header row is row 1, data rows start at 2, and the footer row (if any) is last. A screen reader announces "row 621 of 10001" after scrolling instead of counting the rows that happen to be rendered. The footer row's cells are grid cells | `tests/browser/aria-grid-index.browser.test.ts` |
524
531
 
@@ -159,6 +159,11 @@ var x = r`
159
159
 
160
160
  .ft-header-cell.ft-sortable { cursor: pointer; }
161
161
  .ft-header-cell.ft-sortable:hover { background: var(--ft-header-hover-bg); }
162
+ /* The header cell the keyboard is on (ArrowUp from the first row) — same ring as the active body cell. */
163
+ .ft-header-cell.ft-header-active {
164
+ outline: 2px solid var(--ft-active-color);
165
+ outline-offset: -2px;
166
+ }
162
167
 
163
168
  .ft-header-cell.ft-header-align-center { justify-content: center; }
164
169
  .ft-header-cell.ft-header-align-end { justify-content: flex-end; }
@@ -1887,7 +1892,9 @@ var K = {
1887
1892
  lte: "≤"
1888
1893
  }, q = 120, J = 40, Y = 20, X = 32, Z = 5, Ze = 200, Q = class extends n {
1889
1894
  constructor(...e) {
1890
- super(...e), this.columns = [], this._data = [], this._inUndoRedo = !1, this.clearUndoOnDataChange = !1, this.clearSelectionOnDataChange = !1, this._rowHeightFromCss = X, this._targetFloor = 0, this.showRowNumbers = !1, this.theme = void 0, this.maxRows = 0, this.editable = !0, this.showFilters = !1, this.showContextMenu = !1, this.frozenRows = 0, this.emptyMessage = "No data", this.noMatchingMessage = "No matching data", this.loading = !1, this.importEnabled = !1, this.selectable = !1, this.dataMode = "client", this.footerData = null, this.stylesheets = [], this._scrollTop = 0, this._scrollLeft = 0, this._viewportHeight = 0, this._viewportWidth = 0, this._colLeftOffsets = [], this._totalRowWidth = 0, this._baseStyleSheets = [], this._activeCell = null, this._editingCell = null, this._sortCriteria = [], this._selection = new oe(), this._editing = new se(), this._rowSelection = new A(), this._undo = new j(), this._filters = [], this._filteredIndices = [], this._sortedIndices = [], this._openFilterKey = null, this._rowSelectionVersion = 0, this._lastCheckboxRowIndex = null, this._checkboxShiftPending = !1, this._autocompleteState = null, this._headerMenu = null, this._bodyContextMenu = null, this._commentPopup = null, this._viewDirty = !0, this._isDragging = !1, this._wasDrag = !1, this._resizing = null, this._resizeCleanup = null, this._wasHeaderDrag = !1, this._wasRowDrag = !1, this._colDrag = null, this._colDragIndicatorLeft = null, this._fillDrag = null, this._rowDrag = null, this._rowDragIndicatorY = null, this._findState = null, this._columnWidths = /* @__PURE__ */ new Map(), this._hostResizeObserver = null, this._comments = /* @__PURE__ */ new Map(), this._isDragOver = !1, this._warnedUnconstrained = !1, this._onCommentPopupOutsideClick = () => {
1895
+ super(...e), this.columns = [], this._data = [], this._inUndoRedo = !1, this.clearUndoOnDataChange = !1, this.clearSelectionOnDataChange = !1, this._rowHeightFromCss = X, this._targetFloor = 0, this.showRowNumbers = !1, this.theme = void 0, this.maxRows = 0, this.editable = !0, this.showFilters = !1, this.showContextMenu = !1, this.frozenRows = 0, this.emptyMessage = "No data", this.noMatchingMessage = "No matching data", this.loading = !1, this.importEnabled = !1, this.selectable = !1, this.dataMode = "client", this.footerData = null, this.stylesheets = [], this._scrollTop = 0, this._scrollLeft = 0, this._viewportHeight = 0, this._viewportWidth = 0, this._colLeftOffsets = [], this._totalRowWidth = 0, this._baseStyleSheets = [], this._activeCell = null, this._headerCol = null, this._editingCell = null, this._sortCriteria = [], this._selection = new oe(), this._editing = new se(), this._rowSelection = new A(), this._undo = new j(), this._filters = [], this._filteredIndices = [], this._sortedIndices = [], this._openFilterKey = null, this._rowSelectionVersion = 0, this._lastCheckboxRowIndex = null, this._checkboxShiftPending = !1, this._autocompleteState = null, this._headerMenu = null, this._bodyContextMenu = null, this._commentPopup = null, this._viewDirty = !0, this._isDragging = !1, this._wasDrag = !1, this._resizing = null, this._resizeCleanup = null, this._wasHeaderDrag = !1, this._wasRowDrag = !1, this._colDrag = null, this._colDragIndicatorLeft = null, this._fillDrag = null, this._rowDrag = null, this._rowDragIndicatorY = null, this._findState = null, this._columnWidths = /* @__PURE__ */ new Map(), this._hostResizeObserver = null, this._comments = /* @__PURE__ */ new Map(), this._isDragOver = !1, this._warnedUnconstrained = !1, this._onHostFocus = (e) => {
1896
+ e.composedPath()[0] !== this || this._activeCell || this._headerCol !== null || this.visibleColumns.length !== 0 && this.matches(":focus-visible") && (this._visibleRowCount > 0 ? (this._selection.setActive(0, 0), this._syncActiveCell()) : this._headerCol = 0);
1897
+ }, this._onCommentPopupOutsideClick = () => {
1891
1898
  this._commitCommentPopup();
1892
1899
  }, this._invalidCells = /* @__PURE__ */ new Map(), this._textFilterState = /* @__PURE__ */ new Map(), this._numberFilterState = /* @__PURE__ */ new Map(), this._dateFilterState = /* @__PURE__ */ new Map(), this._emptyFilterState = /* @__PURE__ */ new Map();
1893
1900
  }
@@ -2418,10 +2425,10 @@ var K = {
2418
2425
  };
2419
2426
  }
2420
2427
  connectedCallback() {
2421
- super.connectedCallback(), this._onScroll = this._onScroll.bind(this), this._onKeyDown = this._onKeyDown.bind(this), this._onDocumentClick = this._onDocumentClick.bind(this), this._onContextMenu = this._onContextMenu.bind(this), this._onDragover = this._onDragover.bind(this), this._onDragleave = this._onDragleave.bind(this), this._onDrop = this._onDrop.bind(this), this.addEventListener("scroll", this._onScroll, { passive: !0 }), this.addEventListener("keydown", this._onKeyDown), this.addEventListener("contextmenu", this._onContextMenu), this.addEventListener("dragover", this._onDragover), this.addEventListener("dragleave", this._onDragleave), this.addEventListener("drop", this._onDrop), this.hasAttribute("tabindex") || this.setAttribute("tabindex", "0"), this.setAttribute("role", "grid"), typeof ResizeObserver < "u" && (this._hostResizeObserver = new ResizeObserver(() => this._measureViewport()), this._hostResizeObserver.observe(this));
2428
+ super.connectedCallback(), this._onScroll = this._onScroll.bind(this), this._onKeyDown = this._onKeyDown.bind(this), this._onDocumentClick = this._onDocumentClick.bind(this), this._onContextMenu = this._onContextMenu.bind(this), this._onDragover = this._onDragover.bind(this), this._onDragleave = this._onDragleave.bind(this), this._onDrop = this._onDrop.bind(this), this.addEventListener("scroll", this._onScroll, { passive: !0 }), this.addEventListener("keydown", this._onKeyDown), this.addEventListener("focus", this._onHostFocus), this.addEventListener("contextmenu", this._onContextMenu), this.addEventListener("dragover", this._onDragover), this.addEventListener("dragleave", this._onDragleave), this.addEventListener("drop", this._onDrop), this.hasAttribute("tabindex") || this.setAttribute("tabindex", "0"), this.setAttribute("role", "grid"), typeof ResizeObserver < "u" && (this._hostResizeObserver = new ResizeObserver(() => this._measureViewport()), this._hostResizeObserver.observe(this));
2422
2429
  }
2423
2430
  disconnectedCallback() {
2424
- super.disconnectedCallback(), this.removeEventListener("scroll", this._onScroll), this.removeEventListener("keydown", this._onKeyDown), this.removeEventListener("contextmenu", this._onContextMenu), this.removeEventListener("dragover", this._onDragover), this.removeEventListener("dragleave", this._onDragleave), this.removeEventListener("drop", this._onDrop), document.removeEventListener("click", this._onDocumentClick), document.removeEventListener("mousedown", this._onCommentPopupOutsideClick), this._isDragging = !1, this._wasDrag = !1, this._resizeCleanup &&= (this._resizeCleanup(), null), this._hostResizeObserver &&= (this._hostResizeObserver.disconnect(), null);
2431
+ super.disconnectedCallback(), this.removeEventListener("scroll", this._onScroll), this.removeEventListener("keydown", this._onKeyDown), this.removeEventListener("focus", this._onHostFocus), this.removeEventListener("contextmenu", this._onContextMenu), this.removeEventListener("dragover", this._onDragover), this.removeEventListener("dragleave", this._onDragleave), this.removeEventListener("drop", this._onDrop), document.removeEventListener("click", this._onDocumentClick), document.removeEventListener("mousedown", this._onCommentPopupOutsideClick), this._isDragging = !1, this._wasDrag = !1, this._resizeCleanup &&= (this._resizeCleanup(), null), this._hostResizeObserver &&= (this._hostResizeObserver.disconnect(), null);
2425
2432
  }
2426
2433
  firstUpdated() {
2427
2434
  this._readDensityTokens(), this._measureViewport(), this.shadowRoot && Array.isArray(this.shadowRoot.adoptedStyleSheets) && (this._baseStyleSheets = [...this.shadowRoot.adoptedStyleSheets]), this._syncStylesheets();
@@ -2528,28 +2535,34 @@ var K = {
2528
2535
  this._openFilterKey &&= null, this._headerMenu &&= null, this._bodyContextMenu &&= null;
2529
2536
  }
2530
2537
  _onContextMenu(e) {
2531
- let t = e.composedPath().find((e) => e instanceof HTMLElement && e.classList.contains("ft-cell"));
2532
- if (!t) return;
2538
+ let t = e.composedPath(), n = t.find((e) => e instanceof HTMLElement && e.classList.contains("ft-cell"));
2539
+ if (!n) {
2540
+ if (t[0] === this && this._headerCol !== null) {
2541
+ let t = this.visibleColumns[this._headerCol];
2542
+ t && (e.preventDefault(), this._openColumnMenu(t));
2543
+ }
2544
+ return;
2545
+ }
2533
2546
  e.preventDefault();
2534
- let n = t.parentElement;
2535
- if (!n || !n.classList.contains("ft-row")) return;
2536
- let r = parseInt(t.dataset.colIndex ?? "-1", 10), i = n.style.top, a = Math.round(parseInt(i, 10) / this.rowHeight);
2537
- if (r < 0 || a < 0) return;
2538
- let o = this._toDataIndex(a), s = this.visibleColumns[r];
2539
- if (!s) return;
2540
- let c = this._emit("context-menu", {
2547
+ let r = n.parentElement;
2548
+ if (!r || !r.classList.contains("ft-row")) return;
2549
+ let i = parseInt(n.dataset.colIndex ?? "-1", 10), a = r.style.top, o = Math.round(parseInt(a, 10) / this.rowHeight);
2550
+ if (i < 0 || o < 0) return;
2551
+ let s = this._toDataIndex(o), c = this.visibleColumns[i];
2552
+ if (!c) return;
2553
+ let l = this._emit("context-menu", {
2541
2554
  x: e.clientX,
2542
2555
  y: e.clientY,
2543
- row: o,
2544
- col: r,
2545
- key: s.key,
2546
- value: this.data[o]?.[s.key],
2547
- rowData: this.data[o]
2556
+ row: s,
2557
+ col: i,
2558
+ key: c.key,
2559
+ value: this.data[s]?.[c.key],
2560
+ rowData: this.data[s]
2548
2561
  }, { cancelable: !0 });
2549
- this._cancelCommentPopup(), this.showContextMenu && !c.defaultPrevented && (this._bodyContextMenu = {
2550
- rowIndex: a,
2551
- colIndex: r,
2552
- dataIndex: o,
2562
+ this._cancelCommentPopup(), this.showContextMenu && !l.defaultPrevented && (this._bodyContextMenu = {
2563
+ rowIndex: o,
2564
+ colIndex: i,
2565
+ dataIndex: s,
2553
2566
  x: e.clientX,
2554
2567
  y: e.clientY
2555
2568
  }, requestAnimationFrame(() => {
@@ -2775,7 +2788,7 @@ var K = {
2775
2788
  this._autocompleteState = null, this._applyEdit(e), this._selection.moveDown(), this._syncActiveCell();
2776
2789
  }
2777
2790
  _syncActiveCell() {
2778
- this._activeCell = this._selection.activeCell ? { ...this._selection.activeCell } : null, this._scrollToActiveCell(), this._dispatchSelectionEvent();
2791
+ this._activeCell = this._selection.activeCell ? { ...this._selection.activeCell } : null, this._activeCell && (this._headerCol = null), this._scrollToActiveCell(), this._dispatchSelectionEvent();
2779
2792
  }
2780
2793
  _onKeyDown(e) {
2781
2794
  if (this._editing.current || this._openFilterKey && e.composedPath().some((e) => e instanceof Element && e.classList.contains("ft-filter-dropdown"))) return;
@@ -2789,7 +2802,22 @@ var K = {
2789
2802
  "y"
2790
2803
  ].includes(t) && this._handleCtrlKey(e)) return;
2791
2804
  }
2792
- if (t.length !== 0 && this._visibleRowCount !== 0 && (e.composedPath()[0] === this || e.ctrlKey || e.metaKey)) {
2805
+ if (t.length !== 0 && (e.composedPath()[0] === this || e.ctrlKey || e.metaKey)) {
2806
+ if (this._headerCol !== null) {
2807
+ this._onHeaderKeyDown(e, t);
2808
+ return;
2809
+ }
2810
+ if (this._visibleRowCount === 0) {
2811
+ [
2812
+ "ArrowDown",
2813
+ "ArrowUp",
2814
+ "ArrowLeft",
2815
+ "ArrowRight",
2816
+ "Home",
2817
+ "End"
2818
+ ].includes(e.key) && (e.preventDefault(), this._headerCol = 0);
2819
+ return;
2820
+ }
2793
2821
  if ((e.key === "Enter" || e.key === "F2") && this._activeCell && !_(e)) {
2794
2822
  e.preventDefault();
2795
2823
  let n = t[this._activeCell.col];
@@ -2802,7 +2830,6 @@ var K = {
2802
2830
  "ArrowUp",
2803
2831
  "ArrowLeft",
2804
2832
  "ArrowRight",
2805
- "Tab",
2806
2833
  "Home",
2807
2834
  "End"
2808
2835
  ].includes(e.key)) {
@@ -2825,10 +2852,47 @@ var K = {
2825
2852
  e && this._isCellEditable(e) && this._startEdit();
2826
2853
  return;
2827
2854
  }
2855
+ if (e.key === "ArrowUp" && !e.shiftKey && !e.altKey && this._activeCell?.row === 0) {
2856
+ e.preventDefault(), this._enterHeader(this._activeCell.col);
2857
+ return;
2858
+ }
2828
2859
  this._handleNavigation(e) && (e.preventDefault(), this._syncActiveCell());
2829
2860
  }
2830
2861
  }
2831
2862
  }
2863
+ _onHeaderKeyDown(e, t) {
2864
+ if (e.ctrlKey || e.metaKey) return;
2865
+ let n = Math.min(this._headerCol ?? 0, t.length - 1), r = t[n], i;
2866
+ switch (e.key) {
2867
+ case "ArrowLeft":
2868
+ case "ArrowRight":
2869
+ if (e.altKey) {
2870
+ e.preventDefault(), this._resizeColumnBy(r.key, e.key === "ArrowRight" ? Y : -20);
2871
+ return;
2872
+ }
2873
+ i = e.key === "ArrowLeft" ? Math.max(0, n - 1) : Math.min(t.length - 1, n + 1);
2874
+ break;
2875
+ case "Home":
2876
+ i = 0;
2877
+ break;
2878
+ case "End":
2879
+ i = t.length - 1;
2880
+ break;
2881
+ case "ArrowDown":
2882
+ e.preventDefault(), e.altKey ? this._openColumnMenu(r) : this._visibleRowCount > 0 && (this._selection.setActive(0, n), this._syncActiveCell());
2883
+ return;
2884
+ case "Enter":
2885
+ case " ":
2886
+ if (_(e)) return;
2887
+ e.preventDefault(), r.sortable !== !1 && this._toggleSort(r, e.shiftKey);
2888
+ return;
2889
+ default: return;
2890
+ }
2891
+ e.preventDefault(), this._headerCol = i, this._scrollColumnIntoView(i);
2892
+ }
2893
+ _enterHeader(e) {
2894
+ this._selection.clear(), this._syncActiveCell(), this._headerCol = e, this._scrollColumnIntoView(e);
2895
+ }
2832
2896
  _handleCtrlKey(e) {
2833
2897
  if (!(e.ctrlKey || e.metaKey) || e.altKey) return !1;
2834
2898
  switch (e.key.toLowerCase()) {
@@ -2874,7 +2938,10 @@ var K = {
2874
2938
  case "ArrowDown": return e.shiftKey ? this._selection.shiftMoveDown() : this._selection.moveDown(), !0;
2875
2939
  case "ArrowLeft": return e.shiftKey ? this._selection.shiftMoveLeft() : this._selection.moveLeft(), !0;
2876
2940
  case "ArrowRight": return e.shiftKey ? this._selection.shiftMoveRight() : this._selection.moveRight(), !0;
2877
- case "Tab": return e.shiftKey ? this._selection.movePrev() : this._selection.moveNext(), !0;
2941
+ case "Tab": {
2942
+ let t = this._selection.activeCell, n = e.shiftKey ? this._selection.movePrev() : this._selection.moveNext();
2943
+ return !(t && n && t.row === n.row && t.col === n.col);
2944
+ }
2878
2945
  case "Home": return e.ctrlKey ? this._selection.moveToStart() : this._selection.moveToRowStart(), !0;
2879
2946
  case "End": return e.ctrlKey ? this._selection.moveToEnd() : this._selection.moveToRowEnd(), !0;
2880
2947
  case "Escape": return this._findState ? (this._closeFindPanel(), !0) : (this._selection.clear(), !0);
@@ -3102,24 +3169,29 @@ var K = {
3102
3169
  _scrollToActiveCell() {
3103
3170
  if (!this._activeCell) return;
3104
3171
  let e = this._activeCell.row * this.rowHeight + this.headerHeight, t = e + this.rowHeight;
3105
- this._activeCell.row < this._frozenRowCount || (e < this.scrollTop + this.headerHeight + this.frozenRowsHeight ? this.scrollTop = e - this.headerHeight - this.frozenRowsHeight : t > this.scrollTop + this.clientHeight && (this.scrollTop = t - this.clientHeight));
3106
- let n = this.visibleColumns, r = this._activeCell.col, i = this._colLeftOffsets[r] ?? 0, a = i + this._getColWidth(n[r]);
3107
- i < this.scrollLeft ? this.scrollLeft = i : a > this.scrollLeft + this.clientWidth && (this.scrollLeft = a - this.clientWidth);
3172
+ this._activeCell.row < this._frozenRowCount || (e < this.scrollTop + this.headerHeight + this.frozenRowsHeight ? this.scrollTop = e - this.headerHeight - this.frozenRowsHeight : t > this.scrollTop + this.clientHeight && (this.scrollTop = t - this.clientHeight)), this._scrollColumnIntoView(this._activeCell.col);
3173
+ }
3174
+ _scrollColumnIntoView(e) {
3175
+ let t = this.visibleColumns, n = this._colLeftOffsets[e] ?? 0, r = n + this._getColWidth(t[e]);
3176
+ n < this.scrollLeft ? this.scrollLeft = n : r > this.scrollLeft + this.clientWidth && (this.scrollLeft = r - this.clientWidth);
3108
3177
  }
3109
3178
  _dispatchSelectionEvent() {
3110
3179
  this._emit("cell-select", this._activeCell ? { ...this._activeCell } : null);
3111
3180
  }
3112
- _onHeaderClick(t, n) {
3181
+ _onHeaderClick(e, t) {
3113
3182
  if (this._wasHeaderDrag) {
3114
3183
  this._wasHeaderDrag = !1;
3115
3184
  return;
3116
3185
  }
3117
- let r = this.visibleColumns.indexOf(n);
3118
- if (t.ctrlKey || t.metaKey) {
3119
- this._selectColumn(r);
3186
+ let n = this.visibleColumns.indexOf(t);
3187
+ if (e.ctrlKey || e.metaKey) {
3188
+ this._selectColumn(n);
3120
3189
  return;
3121
3190
  }
3122
- n.sortable !== !1 && (this._sortCriteria = e(this._sortCriteria, n.key, t.shiftKey), this.dataMode !== "server" && this._recomputeView(), this.requestUpdate(), this._emit("sort-change", { criteria: [...this._sortCriteria] }));
3191
+ t.sortable !== !1 && this._toggleSort(t, e.shiftKey);
3192
+ }
3193
+ _toggleSort(t, n) {
3194
+ this._sortCriteria = e(this._sortCriteria, t.key, n), this.dataMode !== "server" && this._recomputeView(), this.requestUpdate(), this._emit("sort-change", { criteria: [...this._sortCriteria] });
3123
3195
  }
3124
3196
  _selectColumn(e) {
3125
3197
  let t = this._visibleRowCount;
@@ -3158,7 +3230,8 @@ var K = {
3158
3230
  n ? "ft-sortable" : "",
3159
3231
  u ? "ft-pinned" : "",
3160
3232
  d === "center" ? "ft-header-align-center" : "",
3161
- d === "end" ? "ft-header-align-end" : ""
3233
+ d === "end" ? "ft-header-align-end" : "",
3234
+ this._headerCol === t ? "ft-header-active" : ""
3162
3235
  ].filter(Boolean).join(" "), p = n ? r ? r.direction === "asc" ? "ascending" : "descending" : "none" : void 0, m = this._getColWidth(e), h = this.headerHeight, g = this._colLeftOffsets[t] ?? 0, _;
3163
3236
  _ = c ? `position: absolute; top: 0; left: ${this._scrollLeft + this._getPinnedLeft(t)}px; width: ${m}px; height: ${h}px; z-index: 4;` : l ? `position: absolute; top: 0; left: ${this._getPinnedRightLeft(t)}px; width: ${m}px; height: ${h}px; z-index: 4;` : `left: ${g}px; width: ${m}px; height: ${h}px;`;
3164
3237
  let v = this._colDrag?.active && this._colDrag.colIndex === t, y = [...f.split(" "), v ? "ft-col-dragging" : ""].filter(Boolean).join(" "), b = this.columns, x = b.findIndex((t) => t.key === e.key), S = [];
@@ -3175,7 +3248,7 @@ var K = {
3175
3248
  @mousedown=${(n) => this._onHeaderMouseDown(n, e, t)}
3176
3249
  @click=${n ? (t) => this._onHeaderClick(t, e) : void 0}>
3177
3250
  ${C ? i`
3178
- <button class="ft-hidden-col-indicator"
3251
+ <button class="ft-hidden-col-indicator" tabindex="-1"
3179
3252
  title=${k("showHiddenColumns")}
3180
3253
  @click=${(t) => {
3181
3254
  t.stopPropagation(), this._showHiddenBefore(e);
@@ -3186,6 +3259,8 @@ var K = {
3186
3259
  ${o >= 0 ? i`<span class="ft-sort-order">${o + 1}</span>` : ""}
3187
3260
  <button class="ft-column-menu-btn ${s ? "ft-filter-active" : ""}"
3188
3261
  type="button"
3262
+ tabindex="-1"
3263
+ data-key=${e.key}
3189
3264
  title=${k("columnMenu")}
3190
3265
  aria-label=${k("columnMenuFor", { header: e.label })}
3191
3266
  aria-haspopup="menu"
@@ -3231,12 +3306,15 @@ var K = {
3231
3306
  this._headerMenu = null;
3232
3307
  return;
3233
3308
  }
3234
- let n = e.currentTarget.getBoundingClientRect();
3309
+ this._openColumnMenu(t, e.currentTarget);
3310
+ }
3311
+ _openColumnMenu(e, t = this._columnMenuButton(e.key)) {
3312
+ let n = t?.getBoundingClientRect();
3235
3313
  this._openFilterKey = null, this._headerMenu = {
3236
- key: t.key,
3237
- x: n.left,
3238
- y: n.bottom,
3239
- hiddenNeighbors: this._hiddenNeighbors(t)
3314
+ key: e.key,
3315
+ x: n?.left ?? 0,
3316
+ y: n?.bottom ?? 0,
3317
+ hiddenNeighbors: this._hiddenNeighbors(e)
3240
3318
  }, requestAnimationFrame(() => {
3241
3319
  document.addEventListener("click", this._onDocumentClick, { once: !0 });
3242
3320
  }), this.updateComplete.then(() => this._menuItems()[0]?.focus());
@@ -3250,7 +3328,7 @@ var K = {
3250
3328
  }
3251
3329
  _closeHeaderMenu(e) {
3252
3330
  let t = this._headerMenu?.key;
3253
- this._headerMenu = null, e && t && this._columnMenuButton(t)?.focus();
3331
+ this._headerMenu = null, e && t && (this._headerCol === null ? this._columnMenuButton(t)?.focus() : this.focus());
3254
3332
  }
3255
3333
  _onHeaderMenuKeydown(e) {
3256
3334
  this._onMenuKeydown(e, ".ft-header-menu", (e) => this._closeHeaderMenu(e));
@@ -3424,11 +3502,34 @@ var K = {
3424
3502
  let s = Math.max(4, Math.min(n.left, i - r.width - 4));
3425
3503
  e.style.left = `${s}px`, e.style.top = `${o}px`;
3426
3504
  }
3505
+ _closeFilterDropdown(e = !0) {
3506
+ let t = this._openFilterKey;
3507
+ if (this._openFilterKey = null, e && t !== null) {
3508
+ if (this._headerCol !== null) {
3509
+ this.focus();
3510
+ return;
3511
+ }
3512
+ this.updateComplete.then(() => {
3513
+ [...this.shadowRoot?.querySelectorAll(".ft-column-menu-btn") ?? []].find((e) => e.dataset.key === t)?.focus();
3514
+ });
3515
+ }
3516
+ }
3517
+ _onFilterDialogKeydown(e) {
3518
+ if (e.key !== "Tab") return;
3519
+ let t = [...e.currentTarget.querySelectorAll("input, select, textarea, button, u-date-picker")].filter((e) => !e.disabled && e.getClientRects().length > 0);
3520
+ if (t.length === 0) return;
3521
+ let n = e.composedPath(), r = t.findIndex((e) => n.includes(e)), i = t.length - 1;
3522
+ e.shiftKey && r <= 0 ? (e.preventDefault(), t[i].focus()) : !e.shiftKey && (r === i || r === -1) && (e.preventDefault(), t[0].focus());
3523
+ }
3427
3524
  _renderFilterDropdown(e) {
3428
3525
  let t = e.type ?? "text";
3429
3526
  return i`
3430
- <div class="ft-filter-dropdown" role="group" aria-label=${k("filterFor", { header: e.label })}
3431
- @click=${(e) => e.stopPropagation()}>
3527
+ <div class="ft-filter-dropdown" role="dialog" aria-label=${k("filterFor", { header: e.label })}
3528
+ @click=${(e) => e.stopPropagation()}
3529
+ @keydown=${{
3530
+ handleEvent: (e) => this._onFilterDialogKeydown(e),
3531
+ capture: !0
3532
+ }}>
3432
3533
  ${t === "boolean" ? this._renderBooleanFilter(e) : t === "number" ? this._renderNumberFilter(e) : t === "date" || t === "datetime" ? this._renderDateFilter(e) : this._renderTextFilter(e)}
3433
3534
  <div class="ft-filter-actions">
3434
3535
  <button class="ft-filter-clear" @click=${() => this._clearColumnFilter(e.key)}>${k("clear")}</button>
@@ -3472,7 +3573,7 @@ var K = {
3472
3573
  (r === "" || r === "empty" || r === "non-empty") && (r ? this._emptyFilterState.set(e, r) : this._emptyFilterState.delete(e), t?.(), this._applyFilterForKey(e));
3473
3574
  }}
3474
3575
  @keydown=${(e) => {
3475
- e.key === "Escape" && (this._openFilterKey = null), e.stopPropagation();
3576
+ e.key === "Escape" && this._closeFilterDropdown(), e.stopPropagation();
3476
3577
  }}>
3477
3578
  <option value="">${k("blankAll")}</option>
3478
3579
  <option value="empty">${k("emptyOnly")}</option>
@@ -3550,7 +3651,7 @@ var K = {
3550
3651
  }), this._applyTextFilter(e.key);
3551
3652
  }}
3552
3653
  @keydown=${(e) => {
3553
- e.key === "Escape" && (this._openFilterKey = null), e.stopPropagation();
3654
+ e.key === "Escape" && this._closeFilterDropdown(), e.stopPropagation();
3554
3655
  }}>
3555
3656
  <option value="contains">${k("contains")}</option>
3556
3657
  <option value="starts">${k("startsWith")}</option>
@@ -3571,7 +3672,7 @@ var K = {
3571
3672
  }), this._emptyFilterState.delete(e.key), this._applyTextFilter(e.key);
3572
3673
  }}
3573
3674
  @keydown=${(e) => {
3574
- e.key === "Escape" && (this._openFilterKey = null), e.stopPropagation();
3675
+ e.key === "Escape" && this._closeFilterDropdown(), e.stopPropagation();
3575
3676
  }}>
3576
3677
  ${this._renderEmptyFilterRow(e.key, () => {
3577
3678
  this._textFilterState.delete(e.key);
@@ -3605,7 +3706,7 @@ var K = {
3605
3706
  }, this._numberFilterState.set(e, i), this._emptyFilterState.delete(e), this._applyFilterForKey(e);
3606
3707
  }}
3607
3708
  @keydown=${(e) => {
3608
- e.key === "Escape" && (this._openFilterKey = null), e.stopPropagation();
3709
+ e.key === "Escape" && this._closeFilterDropdown(), e.stopPropagation();
3609
3710
  }}>
3610
3711
  ${Object.keys(K).map((e) => i`<option value=${e}>${K[e]}</option>`)}
3611
3712
  </select>
@@ -3622,7 +3723,7 @@ var K = {
3622
3723
  }, this._numberFilterState.set(e, i), this._emptyFilterState.delete(e), this._applyFilterForKey(e);
3623
3724
  }}
3624
3725
  @keydown=${(e) => {
3625
- e.key === "Escape" && (this._openFilterKey = null), e.stopPropagation();
3726
+ e.key === "Escape" && this._closeFilterDropdown(), e.stopPropagation();
3626
3727
  }}>
3627
3728
  </div>
3628
3729
  `;
@@ -3639,7 +3740,7 @@ var K = {
3639
3740
  r.join = n, this._numberFilterState.set(e.key, r), this._emptyFilterState.delete(e.key), this._applyFilterForKey(e.key);
3640
3741
  }}
3641
3742
  @keydown=${(e) => {
3642
- e.key === "Escape" && (this._openFilterKey = null), e.stopPropagation();
3743
+ e.key === "Escape" && this._closeFilterDropdown(), e.stopPropagation();
3643
3744
  }}>
3644
3745
  <option value="and">AND</option>
3645
3746
  <option value="or">OR</option>
@@ -3671,7 +3772,7 @@ var K = {
3671
3772
  }
3672
3773
  _renderDateFilter(e) {
3673
3774
  let t = e.type === "datetime", n = this._dateFilterState.get(e.key) ?? {}, r = (e) => e?.slice(0, 10), o = (e) => {
3674
- e.key === "Escape" && $(e.currentTarget) || (e.stopPropagation(), e.key === "Escape" && (this._openFilterKey = null));
3775
+ e.key === "Escape" && $(e.currentTarget) || (e.stopPropagation(), e.key === "Escape" && this._closeFilterDropdown());
3675
3776
  };
3676
3777
  return i`
3677
3778
  <div class="ft-filter-range ft-filter-range-dates">
@@ -3703,7 +3804,7 @@ var K = {
3703
3804
  n === "all" ? this.removeFilter(e.key) : this.setFilter(e.key, (e) => !!e == (n === "true"));
3704
3805
  }}
3705
3806
  @keydown=${(e) => {
3706
- e.key === "Escape" && (this._openFilterKey = null), e.stopPropagation();
3807
+ e.key === "Escape" && this._closeFilterDropdown(), e.stopPropagation();
3707
3808
  }}>
3708
3809
  <option value="all">${k("all")}</option>
3709
3810
  <option value="true">\u2714 ${k("booleanTrue")}</option>
@@ -4468,7 +4569,7 @@ G([s({ type: Array })], Q.prototype, "columns", void 0), G([s({
4468
4569
  })], Q.prototype, "footerData", void 0), G([s({ attribute: !1 })], Q.prototype, "stylesheets", void 0), G([s({
4469
4570
  type: Number,
4470
4571
  attribute: "max-undo-size"
4471
- })], Q.prototype, "maxUndoSize", null), G([c()], Q.prototype, "_scrollTop", void 0), G([c()], Q.prototype, "_scrollLeft", void 0), G([c()], Q.prototype, "_viewportHeight", void 0), G([c()], Q.prototype, "_viewportWidth", void 0), G([c()], Q.prototype, "_activeCell", void 0), G([c()], Q.prototype, "_editingCell", void 0), G([c()], Q.prototype, "_sortCriteria", void 0), G([c()], Q.prototype, "_openFilterKey", void 0), G([c()], Q.prototype, "_rowSelectionVersion", void 0), G([c()], Q.prototype, "_autocompleteState", void 0), G([c()], Q.prototype, "_headerMenu", void 0), G([c()], Q.prototype, "_bodyContextMenu", void 0), G([c()], Q.prototype, "_commentPopup", void 0), G([c()], Q.prototype, "_isDragOver", void 0), Q = G([o("flex-table")], Q);
4572
+ })], Q.prototype, "maxUndoSize", null), G([c()], Q.prototype, "_scrollTop", void 0), G([c()], Q.prototype, "_scrollLeft", void 0), G([c()], Q.prototype, "_viewportHeight", void 0), G([c()], Q.prototype, "_viewportWidth", void 0), G([c()], Q.prototype, "_activeCell", void 0), G([c()], Q.prototype, "_headerCol", void 0), G([c()], Q.prototype, "_editingCell", void 0), G([c()], Q.prototype, "_sortCriteria", void 0), G([c()], Q.prototype, "_openFilterKey", void 0), G([c()], Q.prototype, "_rowSelectionVersion", void 0), G([c()], Q.prototype, "_autocompleteState", void 0), G([c()], Q.prototype, "_headerMenu", void 0), G([c()], Q.prototype, "_bodyContextMenu", void 0), G([c()], Q.prototype, "_commentPopup", void 0), G([c()], Q.prototype, "_isDragOver", void 0), Q = G([o("flex-table")], Q);
4472
4573
  function Qe(e, t) {
4473
4574
  if (t) {
4474
4575
  let t = fe(e);
@@ -108,6 +108,11 @@ export declare class FlexTable extends LitElement {
108
108
  /** The grid's own compiled styles, captured once so `stylesheets` merges without dropping them. */
109
109
  private _baseStyleSheets;
110
110
  private _activeCell;
111
+ /**
112
+ * The header cell the keyboard is on — the header row sits above the first body row (ArrowUp from
113
+ * it). Exclusive with an active body cell: entering one leaves the other.
114
+ */
115
+ private _headerCol;
111
116
  private _editingCell;
112
117
  private _sortCriteria;
113
118
  private _selection;
@@ -394,6 +399,20 @@ export declare class FlexTable extends LitElement {
394
399
  private _selectAutocompleteCandidate;
395
400
  private _syncActiveCell;
396
401
  private _onKeyDown;
402
+ /**
403
+ * The keyboard on the header row: arrows / Home / End move along it, ArrowDown returns to the body,
404
+ * Enter or Space sorts (Shift adds to the sort), Alt+ArrowDown opens the column menu, Alt+Arrow
405
+ * resizes. Tab leaves the grid.
406
+ */
407
+ private _onHeaderKeyDown;
408
+ /** Move the keyboard from the body onto the header cell of visible column `colIndex`. */
409
+ private _enterHeader;
410
+ /**
411
+ * Keyboard focus arriving on a grid with nothing active places it on the first cell (the first header
412
+ * cell when the body is empty), so the first arrow already moves and Tab is not spent entering. A
413
+ * pointer focus places the cell it pressed instead.
414
+ */
415
+ private _onHostFocus;
397
416
  private _handleCtrlKey;
398
417
  private _handleAltKey;
399
418
  /** Widen (positive) or narrow (negative) a visible column by `delta` px, never below its minimum. */
@@ -423,8 +442,12 @@ export declare class FlexTable extends LitElement {
423
442
  private _clearRanges;
424
443
  private _clearRange;
425
444
  private _scrollToActiveCell;
445
+ /** Scroll horizontally so the visible column at `colIndex` is in view — cached column offsets. */
446
+ private _scrollColumnIntoView;
426
447
  private _dispatchSelectionEvent;
427
448
  private _onHeaderClick;
449
+ /** Cycle `col`'s sort (asc → desc → none); `multi` adds it to the existing criteria (Shift). */
450
+ private _toggleSort;
428
451
  private _selectColumn;
429
452
  /** Public API: select an entire column by index. */
430
453
  selectColumn(colIndex: number): void;
@@ -456,6 +479,8 @@ export declare class FlexTable extends LitElement {
456
479
  * and resizing without dragging the 6px handle (WCAG 2.2 SC 2.5.7 / 2.5.8).
457
480
  */
458
481
  private _onColumnMenuBtnClick;
482
+ /** Open `col`'s column menu under `anchor` (its menu button by default) and focus the first item. */
483
+ private _openColumnMenu;
459
484
  private _menuItems;
460
485
  private _columnMenuButton;
461
486
  private _closeHeaderMenu;
@@ -484,6 +509,18 @@ export declare class FlexTable extends LitElement {
484
509
  /** Keep a fixed-position menu inside the viewport — a menu opened near the right or bottom edge would spill off-screen. */
485
510
  private _keepMenuInView;
486
511
  private _adjustFilterDropdown;
512
+ /**
513
+ * Close the filter dialog. From the keyboard (Escape) focus goes back to the column's menu button — the
514
+ * dialog leaves the DOM, and focus inside it would otherwise drop to the page (APG dialog). A click
515
+ * outside closes it without moving focus: the click already put focus where the user meant.
516
+ */
517
+ private _closeFilterDropdown;
518
+ /**
519
+ * Tab stays inside the filter dialog (it floats over the table, fixed to the header — a focus that left
520
+ * it would leave it open and unreachable behind the next control). Capture phase: the controls stop
521
+ * their own keydowns from reaching the grid.
522
+ */
523
+ private _onFilterDialogKeydown;
487
524
  private _renderFilterDropdown;
488
525
  private _buildTextPredicate;
489
526
  private _applyTextFilter;
@@ -1,2 +1,2 @@
1
- import { a as e, i as t, n, o as r, r as i, s as a, t as o } from "./flex-table-BsxF9Q3O.js";
1
+ import { a as e, i as t, n, o as r, r as i, s as a, t as o } from "./flex-table-B64ScsFY.js";
2
2
  export { o as FlexTable, e as RowSelectionState, t as UndoStack, n as effectiveAlign, i as exportData, r as flexTableLocale, a as renderCell };
package/dist/react.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as e } from "./flex-table-BsxF9Q3O.js";
1
+ import { t as e } from "./flex-table-B64ScsFY.js";
2
2
  import { i as t, t as n } from "./query-Dw4iO0ir.js";
3
3
  import { t as r } from "./view-DiLr-TdY.js";
4
4
  import i, { useCallback as a, useEffect as o, useMemo as s, useRef as c, useState as l } from "react";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iyulab/flex-table",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "A minimalist, input-centric data grid web component",
5
5
  "type": "module",
6
6
  "main": "./dist/flex-table.js",
@@ -68,7 +68,10 @@ flex-table {
68
68
 
69
69
  | Key | Action |
70
70
  |---|---|
71
- | Arrows / Tab / Shift+Tab | Move between cells |
71
+ | Arrows / Tab / Shift+Tab | Move between cells; Tab past the last (Shift+Tab before the first) leaves the table |
72
+ | ArrowUp on the first row | Move onto the header row (arrows / Home / End along it, ArrowDown back) |
73
+ | Enter / Space on a header cell | Sort (Shift adds to the sort) |
74
+ | Alt+ArrowDown or context-menu key on a header cell | Open the column menu |
72
75
  | Home / End, Ctrl+Home / Ctrl+End | Row start/end, table start/end |
73
76
  | Shift+Arrow, Shift+Click | Extend range selection |
74
77
  | Shift+Space | Select / deselect the active cell's row (`selectable`; row checkboxes are not Tab stops) |
@@ -84,6 +87,9 @@ flex-table {
84
87
  | Alt+ArrowLeft / Alt+ArrowRight | Resize current column |
85
88
  | Enter / Space on a column menu button | Open the column menu (arrows, Home/End navigate; Escape closes) |
86
89
 
90
+ The table is one Tab stop — keyboard focus arriving on it activates the first cell; the column menu
91
+ buttons are not Tab stops (the header row reaches them).
92
+
87
93
  Ctrl also matches Cmd on macOS. Editing shortcuts are ignored when `editable` is `false`.
88
94
 
89
95
  ## Localization