Skip to content

fix(ui): keep filter row alive during table rebuild so typing never loses focus (rant 2026-08-25T11:15:16) - #148

Merged
argszero merged 1 commit into
mainfrom
fix/tx-filter-focus
Aug 25, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-filter-focus

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

Fix a regression from PR #146 (v0.7.18 server-side column filters): typing into a column-filter input on the transactions page lost focus after every keystroke, making it impossible to type a full word (e.g. "sh" — after "s" the input loses focus).

Root cause: input debounce callback → renderTransactions detects the filter-signature change → loadTransactions() async fetch → after fetch returns, renderTransactionsbuildDataTable executes container.innerHTML = html (full table rebuild) → the old filter input is destroyed, focus lost. The focus-restore code ran after the render that triggered the fetch, not after the DOM rebuild that follows the fetch return.

Fix (host-confirmed option B): the header filter row is separated from the full-table rebuild — buildDataTable now renders the thead (sort buttons + filter row) only once per container and rebuilds only the tbody + pager on every render, so the filter input DOM is never destroyed and focus is naturally preserved.

Related Issue

Rant 2026-08-25T11:15:16 (verbatim):

交易记录页列筛选输入框:每输入一个字母就触发刷新并失去焦点,无法完整输入一个单词(如想输入 "sh",输入 "s" 后焦点丢失)。v0.7.18 服务端列筛选改造(PR #146)后的回归。

【修复方案(宿主确认:只做 B)】重建表格时不动表头筛选行——把筛选输入行从整表重建中分离(独立容器/独立 innerHTML),只重建数据行与分页器;输入框 DOM 永不销毁,焦点天然保留。

Changes

  • ui/js/app.js:
    • tableTheadHtml() — thead (sort buttons + filter row) rendered once, only when the container has no <table> yet; input values seeded from state.filters on first render, afterwards the inputs are the DOM source of truth
    • tableBodyHtml() — data rows, rebuilt on every render
    • buildDataTable() — rebuilds only tbody + pager; container-level one-time event delegation (sort / filter input / pager / page-size / IME) reads the latest config via container._dt, so events survive rebuilds and are never lost after the first keystroke
    • IME composition tracking (compositionstart/compositionend + focusout safety): no table rebuild and no setSelectionRange while composing; a refresh is debounced after compositionend — Chinese IME composition is not interrupted
    • focus restore kept only as a fallback when the input is not already focused (no cursor jumps while typing)
  • ui/index.html: cache-bust bumped to 20260825-2

Tests

  • node --check ui/js/app.js — OK
  • cargo test — 147/147 pass
  • Manual reasoning of the full event flow (debounce → filter-sig reload → tbody-only rebuild → focus preserved)

Checklist

  • Conventional commit message, branch fix/<description>
  • PR based on main (default branch)
  • No behavior change for tables without filter columns (thead persistence is transparent, events delegated)

…oses focus (rant 2026-08-25T11:15:16)

Column-filter inputs on the transactions page lost focus after every
keystroke (regression of PR #146 server-side filters, v0.7.18): the
debounced filter change triggered an async fetch, and the fetch-return
render rebuilt the whole table via innerHTML, destroying the focused
input. Focus-restore ran after the wrong render.

Fix (host-confirmed option B): the header filter row is now rendered
once and survives rebuilds — buildDataTable only rebuilds tbody and the
pager; the input DOM is never destroyed so focus is naturally preserved.

- split thead (sort buttons + filter row) into tableTheadHtml, rendered
  only when the container has no <table> yet (values seeded from
  state.filters; afterwards the inputs are the DOM source of truth)
- tbody (tableBodyHtml) and pager rebuilt on every render as before
- per-render event binding replaced with one-time container-level
  delegation (sort / filter input / pager / page-size / IME), reading
  the latest config via container._dt so events survive rebuilds
- IME composition tracked (compositionstart/end + focusout safety):
  no table rebuild / no setSelectionRange while composing, refresh
  debounced once after compositionend — Chinese IME input is not
  interrupted
- focus restore only as fallback when the input is not focused
- cache-bust 20260825-2
@argszero
argszero merged commit 4ee2a2c into main Aug 25, 2026
1 check passed
@argszero
argszero deleted the fix/tx-filter-focus branch August 25, 2026 03:28
argszero added a commit that referenced this pull request Sep 12, 2026
…age switches (#184)

Setting a column filter on the transactions table and then switching the UI
language emptied the visible table (0 rows and a blank empty-state) while
#tx-count still reported the server's row count, because the filter state
stored the localized option label while filterVal() re-localized on every
comparison. The table header also stayed in the previous language: it is built
once per container lifetime to preserve filter-input focus (#148), so column
titles and dropdown labels never re-rendered.

- TX_COLUMNS type/status options now emit {value, label}: value is the
  language-independent DB value (consume/earn/... , 成功/入账/处理中), label is
  the localized text. filterVal() compares against the raw row value.
- Delete TX_TYPE_INV / TX_STATUS_INV: the "localized label -> DB value"
  inverse maps were keyed by runtime language output, so they could only
  express "translate whatever the dropdown currently shows". With the state
  holding the DB value they have nothing left to do.
- tableTheadHtml renders both {value, label} and plain-string options.
- Rebuild the header on atp:langchange so titles and options render in the
  newly selected language; the state (and therefore the user's filters) is
  preserved.

Verified with a real-DOM A/B probe driving actual input events and
I18n.setLang(): zh->en and en->zh flip from 0 rows + wrong-language header +
blank empty-state to the correct rows and a re-localized header, while the
zh->zh and en->en runs stay clean in both revisions. A separate guard confirms
#148's focus-preserving property is intact (same node identity across typing
and sort re-renders).

Co-authored-by: argszero <argszero@argszerodeMac-mini.local>
argszero added a commit that referenced this pull request Sep 13, 2026
The transactions table's four Token columns (input / cached / output /
total) rendered a sortable header button, but clicking it never
reordered a row — while the Points and Time columns beside them sorted
correctly.

Two faces, one blind spot:

1. The comparator falls back to `row[key]`, and the view rows built by
   `txsToView` do not carry those keys: the exact numbers live under
   `inputRaw` / `cachedRaw` / `outputRaw` / `tokensRaw`, while `tokens`
   and the three `*Tokens` fields hold the K/M *display* strings
   ("5K"). So `Number(undefined)` and `Number("5K")` are NaN, `cmp` is
   NaN, `cmp !== 0` is false and the comparator degenerates to
   "equal" — the rows never move and nothing is reported.
2. The `▲`/`▼` direction marker is emitted only by `tableTheadHtml`,
   which since #148 runs only when the container has no `<table>`
   (that is what keeps the filter inputs alive). At that moment
   `state.sort` is still empty, so the sort state was never shown for
   any column — including the Points column, which does sort.

The fix applies the rule C2054 already established for the Points
column (sort on the value the cell shows) to the class: each token
column declares a `sortVal` that reads its exact numeric field, and the
direction marker is painted in place on every rebuild instead of
relying on the one-shot thead build. Rebuilding the thead was
deliberately avoided — it would destroy the filter inputs and break
#148's focus invariant. `sortArrow()` and `colTitle()` are now the
single definition shared by the thead builder, the in-place painter and
`tableBodyHtml`'s `data-label`.

One file (ui/js/app.js), zero Rust, zero new i18n keys, no Config or
schema change.
argszero added a commit that referenced this pull request Sep 14, 2026
…th controls (#230)

The transactions view filters by type through TWO controls — the `#tx-tabs` strip
(全部/消费/收益) and the 类型 column's `select.th-filter` (the six database
values) — but the state was split: the tabs wrote a module-level `txTab`, the
select wrote `txTable.filters.type`, and `loadTransactions` resolved them with a
documented precedence, `const type = colType || (txTab === "all" ? "" : txTab)`.
The column select therefore wins permanently: once it has a value, a tab click
sets `txTab` and repaints nothing else, so the click is a NO-OP (the request
still carries the column filter). And because the highlight was painted from
`txTab` alone, the strip meanwhile claims a filter that is not the applied one —
with the 类型 select on 赠送, the list shows gift rows while 全部 stays lit.

This is one filtered concept with one state that must be the single source for
the request parameter, the tab highlight, and the select: the fix deletes the
second state (`txTab`) and routes both controls through `txTypeFilter()`
(read) / `setTxTypeFilter()` (write — it also syncs the already-rendered select,
since #148 stopped rebuilding the thead and the widget no longer follows the
state by itself). When the effective value is not one of all/consume/earn
(topup / withdraw / gift / expire), no tab claims to be active: lighting 全部
while the list holds gift rows would be the same lie in the other direction.

Verified with a jsdom probe (real ui/index.html + js/api.js + data.js + i18n.js
+ app.js, only `fetch` stubbed) that drives the real controls and compares all
three artifacts against ONE derivation, `wantTabs(type)` — never a literal, which
would pass by accident whenever the stale highlight happens to spell it.
A/B (baseline pinned to d1faab6 via `git show`; the driver aborts if the
baseline equals the fix): pre-change 5/11 (red B2/B3/B4/B5/C1/C2), three wrong
fixes each rejected — dead click 4/11, unsynced select 9/11, constant highlight
7/11 — fixed tree 11/11 with the built-in control A0 green in every leg.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant