@iyulab/flex-table 0.49.1 → 0.50.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.50.1] - 2026-10-06
4
+
5
+ ### Fixed
6
+
7
+ - **Screen readers hear the active cell.** The table kept the focus on itself and marked the active cell
8
+ with a class only, so assistive technology announced the grid and nothing as the arrow keys moved
9
+ (the host cannot point `aria-activedescendant` into its own shadow tree). The focus now sits on the
10
+ active cell — or the active header cell — while the table has it (roving focus): each move is announced
11
+ with the cell's row, column and content. `document.activeElement` is still the table.
12
+ - When scrolling moves the focused cell out of the rendered window, the focus waits on the table instead
13
+ of staying on a recycled cell that now shows another row; the next key brings it back onto the active
14
+ cell.
15
+
16
+ ## [0.50.0] - 2026-10-06
17
+
18
+ ### Fixed
19
+
20
+ - **Tab no longer traps the focus in the table.** Tab past the last cell (Shift+Tab before the first)
21
+ stayed on that cell, so keyboard focus could never leave the table (WCAG 2.1.2). Tab still walks the
22
+ cells; at either end it now moves on to the next (previous) control on the page.
23
+
24
+ ### Added
25
+
26
+ - **The header row is reachable by keyboard.** ArrowUp from the first row moves onto the column's header
27
+ cell; ArrowLeft / ArrowRight / Home / End move along the header and ArrowDown returns to the body. On a
28
+ header cell, Enter or Space sorts (Shift adds the column to the sort), Alt+ArrowDown or the context-menu
29
+ key opens the column menu, and Alt+ArrowLeft / Alt+ArrowRight resize the column. Escape from that menu
30
+ returns to the same header cell. An empty table keeps the header row reachable.
31
+ - Keyboard focus arriving on a table with nothing active activates the first cell, so the first arrow
32
+ key already moves and no Tab is spent entering.
33
+
34
+ ### Changed
35
+
36
+ - **The column menu buttons are no longer Tab stops** (`tabindex="-1"`, like the row checkboxes) — the
37
+ table is one Tab stop and the header row reaches each column's menu. Pages that relied on tabbing to a
38
+ menu button reach it from the header row instead.
39
+
3
40
  ## [0.49.1] - 2026-10-06
4
41
 
5
42
  ### Fixed
package/README.md CHANGED
@@ -67,7 +67,7 @@ npm install @iyulab/flex-table
67
67
  - **Data Mode** — Client-side or server-side sorting/filtering (`dataMode`)
68
68
  - **Context Menu** — `context-menu` event for custom right-click menus
69
69
  - **React Wrapper** — `@iyulab/flex-table/react` subpath for idiomatic React usage
70
- - **ARIA** — `role="grid"`, `aria-sort`, `aria-selected`, `aria-readonly`, `aria-invalid`, `aria-rowcount`, `aria-colcount`, `aria-rowindex`, `aria-colindex`
70
+ - **ARIA** — `role="grid"` with roving focus (the focus sits on the active cell or header cell, so screen readers announce each move), `aria-sort`, `aria-selected`, `aria-readonly`, `aria-invalid`, `aria-rowcount`, `aria-colcount`, `aria-rowindex`, `aria-colindex`
71
71
 
72
72
  ## Sizing
73
73
 
@@ -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,17 @@ 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.
467
+ While the table has the focus, the focus sits on the active cell (or header cell) itself — `document.activeElement`
468
+ is the table, and its `shadowRoot.activeElement` is that cell.
461
469
 
462
470
  ## Localization
463
471
 
@@ -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,23 @@ 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._focusedCellEl = 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._onFocusIn = (e) => {
1898
+ let t = e.composedPath()[0];
1899
+ t !== this && this._isGridSurface(t) && (this._focusedCellEl = t, this.tabIndex === 0 && (this._hostTabIndexLowered = !0, this.tabIndex = -1));
1900
+ }, this._onFocusOut = (e) => {
1901
+ let t = e.relatedTarget;
1902
+ if (t && (t === this || this.contains(t) || this.shadowRoot?.contains(t))) return;
1903
+ let n = e.composedPath()[0];
1904
+ requestAnimationFrame(() => {
1905
+ if (n instanceof HTMLElement && n === this._focusedCellEl && !n.isConnected) {
1906
+ this._syncGridFocus();
1907
+ return;
1908
+ }
1909
+ document.activeElement !== this && (this._focusedCellEl = null, this._hostTabIndexLowered && (this._hostTabIndexLowered = !1, this.tabIndex = 0));
1910
+ });
1911
+ }, this._hostTabIndexLowered = !1, this._onCommentPopupOutsideClick = () => {
1891
1912
  this._commitCommentPopup();
1892
1913
  }, 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
1914
  }
@@ -2418,10 +2439,10 @@ var K = {
2418
2439
  };
2419
2440
  }
2420
2441
  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));
2442
+ 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.renderRoot.addEventListener("focusin", this._onFocusIn), this.renderRoot.addEventListener("focusout", this._onFocusOut), 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
2443
  }
2423
2444
  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);
2445
+ super.disconnectedCallback(), this.removeEventListener("scroll", this._onScroll), this.removeEventListener("keydown", this._onKeyDown), this.removeEventListener("focus", this._onHostFocus), this.renderRoot.removeEventListener("focusin", this._onFocusIn), this.renderRoot.removeEventListener("focusout", this._onFocusOut), 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
2446
  }
2426
2447
  firstUpdated() {
2427
2448
  this._readDensityTokens(), this._measureViewport(), this.shadowRoot && Array.isArray(this.shadowRoot.adoptedStyleSheets) && (this._baseStyleSheets = [...this.shadowRoot.adoptedStyleSheets]), this._syncStylesheets();
@@ -2472,7 +2493,7 @@ var K = {
2472
2493
  (!(e.size <= 2 && !e.has("data") && !e.has("columns") && !e.has("_openFilterKey") && !e.has("_editingCell") && !e.has("_activeCell") && (e.has("_scrollTop") || e.has("_scrollLeft"))) || this._viewDirty) && (this._recomputeView(), this._viewDirty = !1), this._updateColOffsets(), this._selection.setDimensions(this._visibleRowCount, this.visibleColumns.length), this._rowSelection.setRowCount(this._visibleRowCount);
2473
2494
  }
2474
2495
  updated(e) {
2475
- this._focusEditor(), this._adjustFilterDropdown(), e.has("_headerMenu") && this._keepMenuInView(".ft-header-menu"), e.has("_bodyContextMenu") && this._bodyContextMenu && (this._keepMenuInView(".ft-body-context-menu"), this.shadowRoot?.querySelector(".ft-body-context-menu [role=\"menuitem\"]")?.focus()), e.has("stylesheets") && this._syncStylesheets();
2496
+ this._focusEditor(), this._syncGridFocus(), this._adjustFilterDropdown(), e.has("_headerMenu") && this._keepMenuInView(".ft-header-menu"), e.has("_bodyContextMenu") && this._bodyContextMenu && (this._keepMenuInView(".ft-body-context-menu"), this.shadowRoot?.querySelector(".ft-body-context-menu [role=\"menuitem\"]")?.focus()), e.has("stylesheets") && this._syncStylesheets();
2476
2497
  let t = String(1 + this._visibleRowCount + +!!this.footerData), n = String(this.visibleColumns.length);
2477
2498
  if (this.getAttribute("aria-rowcount") !== t && this.setAttribute("aria-rowcount", t), this.getAttribute("aria-colcount") !== n && this.setAttribute("aria-colcount", n), this.loading ? this.getAttribute("aria-busy") !== "true" && this.setAttribute("aria-busy", "true") : this.hasAttribute("aria-busy") && this.removeAttribute("aria-busy"), e.has("_commentPopup") && this._commentPopup) {
2478
2499
  let e = this.shadowRoot?.querySelector(".ft-comment-popup textarea");
@@ -2528,28 +2549,34 @@ var K = {
2528
2549
  this._openFilterKey &&= null, this._headerMenu &&= null, this._bodyContextMenu &&= null;
2529
2550
  }
2530
2551
  _onContextMenu(e) {
2531
- let t = e.composedPath().find((e) => e instanceof HTMLElement && e.classList.contains("ft-cell"));
2532
- if (!t) return;
2552
+ let t = e.composedPath(), n = t.find((e) => e instanceof HTMLElement && e.classList.contains("ft-cell"));
2553
+ if (!n) {
2554
+ if (t[0] === this && this._headerCol !== null) {
2555
+ let t = this.visibleColumns[this._headerCol];
2556
+ t && (e.preventDefault(), this._openColumnMenu(t));
2557
+ }
2558
+ return;
2559
+ }
2533
2560
  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", {
2561
+ let r = n.parentElement;
2562
+ if (!r || !r.classList.contains("ft-row")) return;
2563
+ let i = parseInt(n.dataset.colIndex ?? "-1", 10), a = r.style.top, o = Math.round(parseInt(a, 10) / this.rowHeight);
2564
+ if (i < 0 || o < 0) return;
2565
+ let s = this._toDataIndex(o), c = this.visibleColumns[i];
2566
+ if (!c) return;
2567
+ let l = this._emit("context-menu", {
2541
2568
  x: e.clientX,
2542
2569
  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]
2570
+ row: s,
2571
+ col: i,
2572
+ key: c.key,
2573
+ value: this.data[s]?.[c.key],
2574
+ rowData: this.data[s]
2548
2575
  }, { cancelable: !0 });
2549
- this._cancelCommentPopup(), this.showContextMenu && !c.defaultPrevented && (this._bodyContextMenu = {
2550
- rowIndex: a,
2551
- colIndex: r,
2552
- dataIndex: o,
2576
+ this._cancelCommentPopup(), this.showContextMenu && !l.defaultPrevented && (this._bodyContextMenu = {
2577
+ rowIndex: o,
2578
+ colIndex: i,
2579
+ dataIndex: s,
2553
2580
  x: e.clientX,
2554
2581
  y: e.clientY
2555
2582
  }, requestAnimationFrame(() => {
@@ -2775,7 +2802,7 @@ var K = {
2775
2802
  this._autocompleteState = null, this._applyEdit(e), this._selection.moveDown(), this._syncActiveCell();
2776
2803
  }
2777
2804
  _syncActiveCell() {
2778
- this._activeCell = this._selection.activeCell ? { ...this._selection.activeCell } : null, this._scrollToActiveCell(), this._dispatchSelectionEvent();
2805
+ this._activeCell = this._selection.activeCell ? { ...this._selection.activeCell } : null, this._activeCell && (this._headerCol = null), this._scrollToActiveCell(), this._dispatchSelectionEvent();
2779
2806
  }
2780
2807
  _onKeyDown(e) {
2781
2808
  if (this._editing.current || this._openFilterKey && e.composedPath().some((e) => e instanceof Element && e.classList.contains("ft-filter-dropdown"))) return;
@@ -2789,7 +2816,22 @@ var K = {
2789
2816
  "y"
2790
2817
  ].includes(t) && this._handleCtrlKey(e)) return;
2791
2818
  }
2792
- if (t.length !== 0 && this._visibleRowCount !== 0 && (e.composedPath()[0] === this || e.ctrlKey || e.metaKey)) {
2819
+ if (t.length !== 0 && (this._isGridSurface(e.composedPath()[0]) || e.ctrlKey || e.metaKey)) {
2820
+ if (this._headerCol !== null) {
2821
+ this._onHeaderKeyDown(e, t);
2822
+ return;
2823
+ }
2824
+ if (this._visibleRowCount === 0) {
2825
+ [
2826
+ "ArrowDown",
2827
+ "ArrowUp",
2828
+ "ArrowLeft",
2829
+ "ArrowRight",
2830
+ "Home",
2831
+ "End"
2832
+ ].includes(e.key) && (e.preventDefault(), this._headerCol = 0);
2833
+ return;
2834
+ }
2793
2835
  if ((e.key === "Enter" || e.key === "F2") && this._activeCell && !_(e)) {
2794
2836
  e.preventDefault();
2795
2837
  let n = t[this._activeCell.col];
@@ -2802,7 +2844,6 @@ var K = {
2802
2844
  "ArrowUp",
2803
2845
  "ArrowLeft",
2804
2846
  "ArrowRight",
2805
- "Tab",
2806
2847
  "Home",
2807
2848
  "End"
2808
2849
  ].includes(e.key)) {
@@ -2825,10 +2866,69 @@ var K = {
2825
2866
  e && this._isCellEditable(e) && this._startEdit();
2826
2867
  return;
2827
2868
  }
2869
+ if (e.key === "ArrowUp" && !e.shiftKey && !e.altKey && this._activeCell?.row === 0) {
2870
+ e.preventDefault(), this._enterHeader(this._activeCell.col);
2871
+ return;
2872
+ }
2828
2873
  this._handleNavigation(e) && (e.preventDefault(), this._syncActiveCell());
2829
2874
  }
2830
2875
  }
2831
2876
  }
2877
+ _onHeaderKeyDown(e, t) {
2878
+ if (e.ctrlKey || e.metaKey) return;
2879
+ let n = Math.min(this._headerCol ?? 0, t.length - 1), r = t[n], i;
2880
+ switch (e.key) {
2881
+ case "ArrowLeft":
2882
+ case "ArrowRight":
2883
+ if (e.altKey) {
2884
+ e.preventDefault(), this._resizeColumnBy(r.key, e.key === "ArrowRight" ? Y : -20);
2885
+ return;
2886
+ }
2887
+ i = e.key === "ArrowLeft" ? Math.max(0, n - 1) : Math.min(t.length - 1, n + 1);
2888
+ break;
2889
+ case "Home":
2890
+ i = 0;
2891
+ break;
2892
+ case "End":
2893
+ i = t.length - 1;
2894
+ break;
2895
+ case "ArrowDown":
2896
+ e.preventDefault(), e.altKey ? this._openColumnMenu(r) : this._visibleRowCount > 0 && (this._selection.setActive(0, n), this._syncActiveCell());
2897
+ return;
2898
+ case "Enter":
2899
+ case " ":
2900
+ if (_(e)) return;
2901
+ e.preventDefault(), r.sortable !== !1 && this._toggleSort(r, e.shiftKey);
2902
+ return;
2903
+ default: return;
2904
+ }
2905
+ e.preventDefault(), this._headerCol = i, this._scrollColumnIntoView(i);
2906
+ }
2907
+ _enterHeader(e) {
2908
+ this._selection.clear(), this._syncActiveCell(), this._headerCol = e, this._scrollColumnIntoView(e);
2909
+ }
2910
+ _isGridSurface(e) {
2911
+ return e === this || e instanceof HTMLElement && (e.classList.contains("ft-cell") || e.classList.contains("ft-header-cell")) && !e.classList.contains("ft-editing");
2912
+ }
2913
+ _keyboardCellEl() {
2914
+ let e = this.shadowRoot;
2915
+ if (!e) return null;
2916
+ if (this._headerCol !== null) return e.querySelector(`.ft-header-cell[data-col-index="${this._headerCol}"]`);
2917
+ let t = this._activeCell;
2918
+ return t ? e.querySelector(`.ft-row[aria-rowindex="${t.row + 2}"] .ft-cell[data-col-index="${t.col}"]`) : null;
2919
+ }
2920
+ _syncGridFocus() {
2921
+ let e = this.shadowRoot;
2922
+ if (!e) return;
2923
+ let t = e.activeElement;
2924
+ if (document.activeElement !== this) {
2925
+ this._focusedCellEl && !this._focusedCellEl.isConnected && (document.activeElement === document.body || document.activeElement === null) && (this._focusedCellEl = null, (this._keyboardCellEl() ?? this).focus({ preventScroll: !0 }));
2926
+ return;
2927
+ }
2928
+ if (t && !this._isGridSurface(t)) return;
2929
+ let n = this._keyboardCellEl() ?? this;
2930
+ n !== (t ?? this) && n.focus({ preventScroll: !0 });
2931
+ }
2832
2932
  _handleCtrlKey(e) {
2833
2933
  if (!(e.ctrlKey || e.metaKey) || e.altKey) return !1;
2834
2934
  switch (e.key.toLowerCase()) {
@@ -2874,7 +2974,10 @@ var K = {
2874
2974
  case "ArrowDown": return e.shiftKey ? this._selection.shiftMoveDown() : this._selection.moveDown(), !0;
2875
2975
  case "ArrowLeft": return e.shiftKey ? this._selection.shiftMoveLeft() : this._selection.moveLeft(), !0;
2876
2976
  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;
2977
+ case "Tab": {
2978
+ let t = this._selection.activeCell, n = e.shiftKey ? this._selection.movePrev() : this._selection.moveNext();
2979
+ return !(t && n && t.row === n.row && t.col === n.col);
2980
+ }
2878
2981
  case "Home": return e.ctrlKey ? this._selection.moveToStart() : this._selection.moveToRowStart(), !0;
2879
2982
  case "End": return e.ctrlKey ? this._selection.moveToEnd() : this._selection.moveToRowEnd(), !0;
2880
2983
  case "Escape": return this._findState ? (this._closeFindPanel(), !0) : (this._selection.clear(), !0);
@@ -3102,24 +3205,29 @@ var K = {
3102
3205
  _scrollToActiveCell() {
3103
3206
  if (!this._activeCell) return;
3104
3207
  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);
3208
+ 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);
3209
+ }
3210
+ _scrollColumnIntoView(e) {
3211
+ let t = this.visibleColumns, n = this._colLeftOffsets[e] ?? 0, r = n + this._getColWidth(t[e]);
3212
+ n < this.scrollLeft ? this.scrollLeft = n : r > this.scrollLeft + this.clientWidth && (this.scrollLeft = r - this.clientWidth);
3108
3213
  }
3109
3214
  _dispatchSelectionEvent() {
3110
3215
  this._emit("cell-select", this._activeCell ? { ...this._activeCell } : null);
3111
3216
  }
3112
- _onHeaderClick(t, n) {
3217
+ _onHeaderClick(e, t) {
3113
3218
  if (this._wasHeaderDrag) {
3114
3219
  this._wasHeaderDrag = !1;
3115
3220
  return;
3116
3221
  }
3117
- let r = this.visibleColumns.indexOf(n);
3118
- if (t.ctrlKey || t.metaKey) {
3119
- this._selectColumn(r);
3222
+ let n = this.visibleColumns.indexOf(t);
3223
+ if (e.ctrlKey || e.metaKey) {
3224
+ this._selectColumn(n);
3120
3225
  return;
3121
3226
  }
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] }));
3227
+ t.sortable !== !1 && this._toggleSort(t, e.shiftKey);
3228
+ }
3229
+ _toggleSort(t, n) {
3230
+ this._sortCriteria = e(this._sortCriteria, t.key, n), this.dataMode !== "server" && this._recomputeView(), this.requestUpdate(), this._emit("sort-change", { criteria: [...this._sortCriteria] });
3123
3231
  }
3124
3232
  _selectColumn(e) {
3125
3233
  let t = this._visibleRowCount;
@@ -3158,7 +3266,8 @@ var K = {
3158
3266
  n ? "ft-sortable" : "",
3159
3267
  u ? "ft-pinned" : "",
3160
3268
  d === "center" ? "ft-header-align-center" : "",
3161
- d === "end" ? "ft-header-align-end" : ""
3269
+ d === "end" ? "ft-header-align-end" : "",
3270
+ this._headerCol === t ? "ft-header-active" : ""
3162
3271
  ].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
3272
  _ = 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
3273
  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 = [];
@@ -3167,6 +3276,7 @@ var K = {
3167
3276
  return i`
3168
3277
  <div class=${y}
3169
3278
  role="columnheader"
3279
+ tabindex="-1"
3170
3280
  aria-colindex=${t + 1}
3171
3281
  data-col-index=${t}
3172
3282
  style=${_}
@@ -3175,7 +3285,7 @@ var K = {
3175
3285
  @mousedown=${(n) => this._onHeaderMouseDown(n, e, t)}
3176
3286
  @click=${n ? (t) => this._onHeaderClick(t, e) : void 0}>
3177
3287
  ${C ? i`
3178
- <button class="ft-hidden-col-indicator"
3288
+ <button class="ft-hidden-col-indicator" tabindex="-1"
3179
3289
  title=${k("showHiddenColumns")}
3180
3290
  @click=${(t) => {
3181
3291
  t.stopPropagation(), this._showHiddenBefore(e);
@@ -3186,6 +3296,7 @@ var K = {
3186
3296
  ${o >= 0 ? i`<span class="ft-sort-order">${o + 1}</span>` : ""}
3187
3297
  <button class="ft-column-menu-btn ${s ? "ft-filter-active" : ""}"
3188
3298
  type="button"
3299
+ tabindex="-1"
3189
3300
  data-key=${e.key}
3190
3301
  title=${k("columnMenu")}
3191
3302
  aria-label=${k("columnMenuFor", { header: e.label })}
@@ -3232,12 +3343,15 @@ var K = {
3232
3343
  this._headerMenu = null;
3233
3344
  return;
3234
3345
  }
3235
- let n = e.currentTarget.getBoundingClientRect();
3346
+ this._openColumnMenu(t, e.currentTarget);
3347
+ }
3348
+ _openColumnMenu(e, t = this._columnMenuButton(e.key)) {
3349
+ let n = t?.getBoundingClientRect();
3236
3350
  this._openFilterKey = null, this._headerMenu = {
3237
- key: t.key,
3238
- x: n.left,
3239
- y: n.bottom,
3240
- hiddenNeighbors: this._hiddenNeighbors(t)
3351
+ key: e.key,
3352
+ x: n?.left ?? 0,
3353
+ y: n?.bottom ?? 0,
3354
+ hiddenNeighbors: this._hiddenNeighbors(e)
3241
3355
  }, requestAnimationFrame(() => {
3242
3356
  document.addEventListener("click", this._onDocumentClick, { once: !0 });
3243
3357
  }), this.updateComplete.then(() => this._menuItems()[0]?.focus());
@@ -3251,7 +3365,7 @@ var K = {
3251
3365
  }
3252
3366
  _closeHeaderMenu(e) {
3253
3367
  let t = this._headerMenu?.key;
3254
- this._headerMenu = null, e && t && this._columnMenuButton(t)?.focus();
3368
+ this._headerMenu = null, e && t && (this._headerCol === null ? this._columnMenuButton(t)?.focus() : this.focus());
3255
3369
  }
3256
3370
  _onHeaderMenuKeydown(e) {
3257
3371
  this._onMenuKeydown(e, ".ft-header-menu", (e) => this._closeHeaderMenu(e));
@@ -3427,9 +3541,15 @@ var K = {
3427
3541
  }
3428
3542
  _closeFilterDropdown(e = !0) {
3429
3543
  let t = this._openFilterKey;
3430
- this._openFilterKey = null, e && t !== null && this.updateComplete.then(() => {
3431
- [...this.shadowRoot?.querySelectorAll(".ft-column-menu-btn") ?? []].find((e) => e.dataset.key === t)?.focus();
3432
- });
3544
+ if (this._openFilterKey = null, e && t !== null) {
3545
+ if (this._headerCol !== null) {
3546
+ this.focus();
3547
+ return;
3548
+ }
3549
+ this.updateComplete.then(() => {
3550
+ [...this.shadowRoot?.querySelectorAll(".ft-column-menu-btn") ?? []].find((e) => e.dataset.key === t)?.focus();
3551
+ });
3552
+ }
3433
3553
  }
3434
3554
  _onFilterDialogKeydown(e) {
3435
3555
  if (e.key !== "Tab") return;
@@ -4350,6 +4470,7 @@ var K = {
4350
4470
  return i`
4351
4471
  <div class=${E ? C + " ft-has-comment" : C}
4352
4472
  role="gridcell"
4473
+ tabindex="-1"
4353
4474
  aria-colindex=${r + 1}
4354
4475
  data-col-index=${r}
4355
4476
  aria-selected=${v ? "true" : "false"}
@@ -4486,7 +4607,7 @@ G([s({ type: Array })], Q.prototype, "columns", void 0), G([s({
4486
4607
  })], Q.prototype, "footerData", void 0), G([s({ attribute: !1 })], Q.prototype, "stylesheets", void 0), G([s({
4487
4608
  type: Number,
4488
4609
  attribute: "max-undo-size"
4489
- })], 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);
4610
+ })], 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);
4490
4611
  function Qe(e, t) {
4491
4612
  if (t) {
4492
4613
  let t = fe(e);
@@ -108,6 +108,13 @@ 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;
116
+ /** The cell (body or header) that last held the keyboard focus — to take it back if a re-render drops it. */
117
+ private _focusedCellEl;
111
118
  private _editingCell;
112
119
  private _sortCriteria;
113
120
  private _selection;
@@ -394,6 +401,40 @@ export declare class FlexTable extends LitElement {
394
401
  private _selectAutocompleteCandidate;
395
402
  private _syncActiveCell;
396
403
  private _onKeyDown;
404
+ /**
405
+ * The keyboard on the header row: arrows / Home / End move along it, ArrowDown returns to the body,
406
+ * Enter or Space sorts (Shift adds to the sort), Alt+ArrowDown opens the column menu, Alt+Arrow
407
+ * resizes. Tab leaves the grid.
408
+ */
409
+ private _onHeaderKeyDown;
410
+ /** Move the keyboard from the body onto the header cell of visible column `colIndex`. */
411
+ private _enterHeader;
412
+ /**
413
+ * Keyboard focus arriving on a grid with nothing active places it on the first cell (the first header
414
+ * cell when the body is empty), so the first arrow already moves and Tab is not spent entering. A
415
+ * pointer focus places the cell it pressed instead.
416
+ */
417
+ private _onHostFocus;
418
+ /**
419
+ * The grid's own keyboard surface: the host, the active body cell, the active header cell. Keys on
420
+ * anything else inside it (an editor, a menu, a button a cell renders) belong to that control.
421
+ */
422
+ private _isGridSurface;
423
+ /**
424
+ * The element the keyboard is on — the active header cell, else the active body cell — when it is
425
+ * rendered (the body scrolls it out of the virtual window), else `null`.
426
+ */
427
+ private _keyboardCellEl;
428
+ /**
429
+ * Roving focus: while the grid holds the focus, the focus sits on the cell the keyboard is on, so
430
+ * assistive technology announces each move (the host cannot point `aria-activedescendant` into its
431
+ * own shadow tree). It moves only between the host and the grid's cells — never out of an editor,
432
+ * a menu or a control that holds it.
433
+ */
434
+ private _syncGridFocus;
435
+ private _onFocusIn;
436
+ private _onFocusOut;
437
+ private _hostTabIndexLowered;
397
438
  private _handleCtrlKey;
398
439
  private _handleAltKey;
399
440
  /** Widen (positive) or narrow (negative) a visible column by `delta` px, never below its minimum. */
@@ -423,8 +464,12 @@ export declare class FlexTable extends LitElement {
423
464
  private _clearRanges;
424
465
  private _clearRange;
425
466
  private _scrollToActiveCell;
467
+ /** Scroll horizontally so the visible column at `colIndex` is in view — cached column offsets. */
468
+ private _scrollColumnIntoView;
426
469
  private _dispatchSelectionEvent;
427
470
  private _onHeaderClick;
471
+ /** Cycle `col`'s sort (asc → desc → none); `multi` adds it to the existing criteria (Shift). */
472
+ private _toggleSort;
428
473
  private _selectColumn;
429
474
  /** Public API: select an entire column by index. */
430
475
  selectColumn(colIndex: number): void;
@@ -456,6 +501,8 @@ export declare class FlexTable extends LitElement {
456
501
  * and resizing without dragging the 6px handle (WCAG 2.2 SC 2.5.7 / 2.5.8).
457
502
  */
458
503
  private _onColumnMenuBtnClick;
504
+ /** Open `col`'s column menu under `anchor` (its menu button by default) and focus the first item. */
505
+ private _openColumnMenu;
459
506
  private _menuItems;
460
507
  private _columnMenuButton;
461
508
  private _closeHeaderMenu;
@@ -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-CpAhHipx.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-Hx-xTm0e.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-CpAhHipx.js";
1
+ import { t as e } from "./flex-table-Hx-xTm0e.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.1",
3
+ "version": "0.50.1",
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,10 @@ 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 with roving focus — the focus sits on the active cell or header cell (screen
91
+ readers announce each move); keyboard focus arriving on it activates the first cell; the column menu
92
+ buttons are not Tab stops (the header row reaches them).
93
+
87
94
  Ctrl also matches Cmd on macOS. Editing shortcuts are ignored when `editable` is `false`.
88
95
 
89
96
  ## Localization