@iyulab/flex-table 0.50.0 → 0.50.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.50.2] - 2026-10-06
4
+
5
+ ### Documentation
6
+
7
+ - Shift+F10 and the context-menu key on the active cell open the cell menu at that cell (and fire
8
+ `context-menu`) — documented and covered by a test. It works since 0.50.1, whose roving focus puts the
9
+ focus on the cell the browser opens the menu for.
10
+
11
+ ## [0.50.1] - 2026-10-06
12
+
13
+ ### Fixed
14
+
15
+ - **Screen readers hear the active cell.** The table kept the focus on itself and marked the active cell
16
+ with a class only, so assistive technology announced the grid and nothing as the arrow keys moved
17
+ (the host cannot point `aria-activedescendant` into its own shadow tree). The focus now sits on the
18
+ active cell — or the active header cell — while the table has it (roving focus): each move is announced
19
+ with the cell's row, column and content. `document.activeElement` is still the table.
20
+ - When scrolling moves the focused cell out of the rendered window, the focus waits on the table instead
21
+ of staying on a recycled cell that now shows another row; the next key brings it back onto the active
22
+ cell.
23
+
3
24
  ## [0.50.0] - 2026-10-06
4
25
 
5
26
  ### Fixed
package/README.md CHANGED
@@ -65,9 +65,9 @@ npm install @iyulab/flex-table
65
65
  - **Row Numbers** — Optional `show-row-numbers` attribute with sticky positioning
66
66
  - **Footer Row** — Summary/aggregate row via `footer-data` property
67
67
  - **Data Mode** — Client-side or server-side sorting/filtering (`dataMode`)
68
- - **Context Menu** — `context-menu` event for custom right-click menus
68
+ - **Context Menu** — `context-menu` event for custom right-click menus; Shift+F10 or the context-menu key opens it on the active cell
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
 
@@ -351,7 +351,7 @@ type-checks without a cast. The React wrapper's `on*` props carry the same types
351
351
  | Event | Detail | Description |
352
352
  |-------|--------|-------------|
353
353
  | `undo-state-change` | `{ canUndo, canRedo }` | Undo/redo availability changed |
354
- | `context-menu` | `{ x, y, row, col, key, value, rowData }` | Right-click on cell. Cancelable — `preventDefault()` suppresses the built-in menu |
354
+ | `context-menu` | `{ x, y, row, col, key, value, rowData }` | Right-click on a cell, or Shift+F10 / the context-menu key on the active cell. Cancelable — `preventDefault()` suppresses the built-in menu |
355
355
 
356
356
  ## CSS Custom Properties
357
357
 
@@ -458,12 +458,15 @@ package dependency; CSS custom properties are resolved at render time, not impor
458
458
  | ArrowLeft / ArrowRight / Home / End (header row) | Move along the header; ArrowDown returns to the first row |
459
459
  | Enter / Space (header row) | Sort the column (ascending → descending → none); with Shift, add it to the sort |
460
460
  | Alt+ArrowDown or the context-menu key (header row) | Open the column menu |
461
+ | Shift+F10 or the context-menu key (a body cell) | Open the cell menu (`show-context-menu`) at the active cell / fire `context-menu` |
461
462
  | Enter / Space on a column menu button | Open the column menu |
462
463
  | ArrowUp / ArrowDown / Home / End (column menu) | Move between menu items |
463
464
  | Escape (column menu) | Close the menu and return focus to where it was opened from |
464
465
 
465
466
  The table is one Tab stop: keyboard focus arriving on it activates the first cell (the first header cell
466
467
  when there are no rows), and the column menu buttons and row checkboxes are reached by keys, not Tab.
468
+ While the table has the focus, the focus sits on the active cell (or header cell) itself — `document.activeElement`
469
+ is the table, and its `shadowRoot.activeElement` is that cell.
467
470
 
468
471
  ## Localization
469
472
 
@@ -1892,9 +1892,23 @@ var K = {
1892
1892
  lte: "≤"
1893
1893
  }, q = 120, J = 40, Y = 20, X = 32, Z = 5, Ze = 200, Q = class extends n {
1894
1894
  constructor(...e) {
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) => {
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
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 = () => {
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 = () => {
1898
1912
  this._commitCommentPopup();
1899
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();
1900
1914
  }
@@ -2425,10 +2439,10 @@ var K = {
2425
2439
  };
2426
2440
  }
2427
2441
  connectedCallback() {
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));
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));
2429
2443
  }
2430
2444
  disconnectedCallback() {
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);
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);
2432
2446
  }
2433
2447
  firstUpdated() {
2434
2448
  this._readDensityTokens(), this._measureViewport(), this.shadowRoot && Array.isArray(this.shadowRoot.adoptedStyleSheets) && (this._baseStyleSheets = [...this.shadowRoot.adoptedStyleSheets]), this._syncStylesheets();
@@ -2479,7 +2493,7 @@ var K = {
2479
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);
2480
2494
  }
2481
2495
  updated(e) {
2482
- 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();
2483
2497
  let t = String(1 + this._visibleRowCount + +!!this.footerData), n = String(this.visibleColumns.length);
2484
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) {
2485
2499
  let e = this.shadowRoot?.querySelector(".ft-comment-popup textarea");
@@ -2802,7 +2816,7 @@ var K = {
2802
2816
  "y"
2803
2817
  ].includes(t) && this._handleCtrlKey(e)) return;
2804
2818
  }
2805
- if (t.length !== 0 && (e.composedPath()[0] === this || e.ctrlKey || e.metaKey)) {
2819
+ if (t.length !== 0 && (this._isGridSurface(e.composedPath()[0]) || e.ctrlKey || e.metaKey)) {
2806
2820
  if (this._headerCol !== null) {
2807
2821
  this._onHeaderKeyDown(e, t);
2808
2822
  return;
@@ -2893,6 +2907,28 @@ var K = {
2893
2907
  _enterHeader(e) {
2894
2908
  this._selection.clear(), this._syncActiveCell(), this._headerCol = e, this._scrollColumnIntoView(e);
2895
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
+ }
2896
2932
  _handleCtrlKey(e) {
2897
2933
  if (!(e.ctrlKey || e.metaKey) || e.altKey) return !1;
2898
2934
  switch (e.key.toLowerCase()) {
@@ -3240,6 +3276,7 @@ var K = {
3240
3276
  return i`
3241
3277
  <div class=${y}
3242
3278
  role="columnheader"
3279
+ tabindex="-1"
3243
3280
  aria-colindex=${t + 1}
3244
3281
  data-col-index=${t}
3245
3282
  style=${_}
@@ -4433,6 +4470,7 @@ var K = {
4433
4470
  return i`
4434
4471
  <div class=${E ? C + " ft-has-comment" : C}
4435
4472
  role="gridcell"
4473
+ tabindex="-1"
4436
4474
  aria-colindex=${r + 1}
4437
4475
  data-col-index=${r}
4438
4476
  aria-selected=${v ? "true" : "false"}
@@ -113,6 +113,8 @@ export declare class FlexTable extends LitElement {
113
113
  * it). Exclusive with an active body cell: entering one leaves the other.
114
114
  */
115
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;
116
118
  private _editingCell;
117
119
  private _sortCriteria;
118
120
  private _selection;
@@ -413,6 +415,26 @@ export declare class FlexTable extends LitElement {
413
415
  * pointer focus places the cell it pressed instead.
414
416
  */
415
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;
416
438
  private _handleCtrlKey;
417
439
  private _handleAltKey;
418
440
  /** Widen (positive) or narrow (negative) a visible column by `delta` px, never below its minimum. */
@@ -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-B64ScsFY.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-B64ScsFY.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.50.0",
3
+ "version": "0.50.2",
4
4
  "description": "A minimalist, input-centric data grid web component",
5
5
  "type": "module",
6
6
  "main": "./dist/flex-table.js",
@@ -72,6 +72,7 @@ flex-table {
72
72
  | ArrowUp on the first row | Move onto the header row (arrows / Home / End along it, ArrowDown back) |
73
73
  | Enter / Space on a header cell | Sort (Shift adds to the sort) |
74
74
  | Alt+ArrowDown or context-menu key on a header cell | Open the column menu |
75
+ | Shift+F10 or context-menu key on a body cell | Open the cell menu at the active cell (fires `context-menu`) |
75
76
  | Home / End, Ctrl+Home / Ctrl+End | Row start/end, table start/end |
76
77
  | Shift+Arrow, Shift+Click | Extend range selection |
77
78
  | Shift+Space | Select / deselect the active cell's row (`selectable`; row checkboxes are not Tab stops) |
@@ -87,7 +88,8 @@ flex-table {
87
88
  | Alt+ArrowLeft / Alt+ArrowRight | Resize current column |
88
89
  | Enter / Space on a column menu button | Open the column menu (arrows, Home/End navigate; Escape closes) |
89
90
 
90
- The table is one Tab stop — keyboard focus arriving on it activates the first cell; the column menu
91
+ The table is one Tab stop with roving focus — the focus sits on the active cell or header cell (screen
92
+ readers announce each move); keyboard focus arriving on it activates the first cell; the column menu
91
93
  buttons are not Tab stops (the header row reaches them).
92
94
 
93
95
  Ctrl also matches Cmd on macOS. Editing shortcuts are ignored when `editable` is `false`.