Skip to content

feat(frontend): add horizontal scroll affordances to wide data tables - #1332

Merged
IAmJSD merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-185
Oct 24, 2026
Merged

IAmJSD merged 4 commits into
LeanerCloud:mainfrom
IAmJSD:ui/issue-185

Conversation

@IAmJSD

@IAmJSD IAmJSD commented Oct 24, 2026 •

Copy link
Copy Markdown
Collaborator

On Opportunities at 1280px the table is 1429px wide inside a 1000px card, so the last five columns are cut off and Monthly Savings is half clipped, with nothing on screen saying so: the table's only horizontal scrollbar sits at the bottom of a roughly 3000px-tall table, under the sticky action box. Tabbing onto a row under the action box also left the focused control hidden. This PR fades the clipped edge of any wide table, adds a scrollbar strip that sticks just above the Opportunities action box, and scrolls keyboard-focused rows clear of the sticky bars.

Closes #185

What changed

  • New frontend/src/lib/table-scroll.ts (197 lines), started from frontend/src/index.ts on DOMContentLoaded:
    • Any table scroller with hidden columns gets .scroll-hint-left / .scroll-hint-right, which fade that edge out with a CSS mask. This applies to every table in the app (including the purchase modal and history tables), because it detects whichever element actually scrolls.
    • After #recommendations-list > table it inserts an aria-hidden .table-scroll-strip that sticks just above the action box and stays in sync with the table's scrollLeft in both directions. The table's own scrollbar is hidden so only one bar shows; touch, trackpad and Shift+wheel still scroll the table directly.
    • It sets --action-box-offset to the action box's real height (80px on desktop, 136px at 390px), used by the list's padding-bottom (components.css) and by the strip.
    • A keyboard-only focusin handler (:focus-visible) scrolls the page so a focused control in the list clears the topbar, strip and action box.
    • It refreshes after DOM mutations (debounced with requestAnimationFrame), on resize and on every captured scroll.
  • CSS appended to frontend/src/styles/responsive.css; two lines changed in frontend/src/styles/components.css. The three properties the module sets (--scroll-hint-sb-w, --scroll-hint-sb-h, --action-box-offset) also get :root defaults in CSS (0px, 0px, var(--action-box-height)), so every custom property the stylesheets read is defined there. The module's inline values still override them.
  • Tests: new src/__tests__/table-scroll.test.ts (18 tests), one wiring test in src/__tests__/index.test.ts, and a new Playwright spec tests-e2e/table-scroll-affordance.spec.ts (4 tests).

Why this approach

The issue lists several options. These are the ones that work without touching the 5755-line recommendations.ts or changing which data is shown.

  • Making the table its own vertical scroll area with a sticky header was tried and rejected: at page top the bottom ~290px of that box (and its scrollbar) would still sit under the action box, and the mouse wheel gets stuck scrolling the inner table instead of the page. A mirrored scrollbar keeps page scrolling as it is.
  • A mask is used instead of a background-attachment shadow because hovered and highlighted rows paint over a background shadow.
  • A focus handler is used instead of CSS scroll-padding because Chromium ignores scroll-padding when focusing an element it considers already visible (the e2e test failed with scroll-padding alone).

Screenshots

Before is main (a14dc8f2), after is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API, except where a narrower width is noted. The fixture has 40 recommendations, and Chromium's --hide-scrollbars is disabled so real scrollbars render.

Before After
opportunities-1280 before opportunities-1280 after
opportunities-390 before opportunities-390 after
keyboard-focus before keyboard-focus after
purchase-modal before purchase-modal after
  1. Opportunities at 1280px, page top. Before: the table stops at Monthly Savings with no sign that five more columns exist, and no horizontal scrollbar on screen. After: the right edge fades out, and a scrollbar strip just above the action box shows how much of the table is in view.
  2. Opportunities at 390px, scrolled to the table. Before: Service is cut off at the edge with no cue. After: the right edge fades and the strip sits above the taller, wrapped action box.
  3. Keyboard focus. Before: after Tab, the focused Plan button (blue ring, bottom left) is hidden under the action box. After: the same Tab scrolls the page so the focused button sits above the strip and action box.
  4. Review Purchase dialog at 1024px. Before: the Term and Payment columns are cut off and only the scrollbar hints at more. After: the clipped Term column fades and the scrollbar stays at full opacity, showing the fade on a table other than Opportunities.

Verification

Run on this branch's head 6f8dd286 on top of main a14dc8f2:

  • npm run lint: 0 errors (142 warnings, all pre-existing and none in the changed files). Typecheck clean, build OK (only the usual size warning).
  • Jest: 110 suites, 3505 passed, 1 skipped. New and changed suites with --randomize: 28/28. table-scroll.test.ts covers scroller detection, hint states, strip insertion, range and two-way sync, the action-box offset, focus scrolling (including mouse focus and focus outside the list), and that every property the module sets has a :root default in the stylesheets. With fix(frontend): undefined CSS custom properties and inline-styled MFA panels #745's css-custom-properties.test.ts applied on top of this branch, it passes 111/111. Jest printed "a worker process has failed to exit gracefully"; the new suites start no timers, but whether this also happens on main was not checked.
  • Playwright: table-scroll-affordance.spec.ts checks the fade, strip position and sync at 1280px and 390px, that Tab onto a row under the action box scrolls it clear, and that a table that fits at 1920px gets no fade and no strip. Full suite: 161/161 passed, including page-overflow (fix(frontend): page-level horizontal scroll on Opportunities, Purchases and Admin #703).
  • Secrets scan clean (planted key flagged). scripts/test-git-secrets-allowlist.sh: 137 PASS, 0 FAIL.

Notes for review

Design points:

  • Fade strength: content fades to 12% opacity over the last 4rem. Tunable in responsive.css (--scroll-hint-fade / rgb(0 0 0 / 0.12)).
  • The mask also clips the table's own 2px drop shadow while a fade is showing.
  • The Opportunities table's native scrollbar is hidden (scrollbar-width: none) in favor of the strip, which is styled with ::-webkit-scrollbar so it stays visible with macOS overlay scrollbars. Firefox on macOS falls back to scrollbar-color and may only show it on hover.
  • The strip shows at every width, including phones.
  • Keyboard focus in the list now scrolls the page smoothly, using the existing scroll-behavior: smooth (already disabled under reduced motion).

Not included: sticky first columns, a leaner default column set (a product decision; the Columns menu already lets users hide columns), and a card layout on narrow screens. The issue lists these as alternatives to a scroll affordance. Also unchanged from main: re-rendering the list (for example expanding a group) rebuilds the table and resets its horizontal scroll to the left.

Overlaps

Note for the UI owner

This changes UI code that @cristim owns. IAmJSD reviewed the before/after screenshots and asked for it to be merged; any design point you disagree with can be a follow-up.

🤖 Generated with Claude Code

IAmJSD and others added 2 commits October 23, 2026 22:25
…llbar on screen

Wide tables scroll inside their own box since LeanerCloud#703, but nothing showed
that columns were cut off on the right. On Opportunities (16 columns at
1280px, the last five clipped) the table's only horizontal scrollbar sat
at the bottom of a page-tall table, under the sticky action box, and a
row reached with Tab could sit hidden under that box.

lib/table-scroll.ts, wired from index.ts:
- Any table scroller with hidden columns fades that edge out
  (.scroll-hint-left/right, a CSS mask, so row and cell backgrounds
  cannot cover it; the mask leaves scrollbars at full opacity).
- The Opportunities table gets a scrollbar strip that sticks just above
  the action box and mirrors the table's scroll position both ways. The
  table's own bar is hidden so only one shows; touch, trackpad and
  Shift+wheel still scroll the table directly.
- --action-box-offset tracks the action box's real height (it wraps to
  136px at 390px), used by the list padding and the strip.
- Keyboard focus inside the list scrolls the page so the focused row is
  clear of the topbar, strip and action box. Browsers do not do this
  themselves because a row under a sticky bar counts as on screen.

Refs LeanerCloud#185

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jest covers the scroller detection, edge hints, strip sync and focus
scrolling with stubbed layout (jsdom has none). The Playwright spec
checks the real rendering at 1280px and 390px with 40 rows: the
clipped edge fades, the strip sits on screen right above the action
box and stays in step with the table both ways, a table that fits gets
neither, and Tab onto a row under the action box scrolls it clear.

Refs LeanerCloud#185

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD IAmJSD added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/many Affects most users effort/m Days type/feat New capability labels Oct 24, 2026
@coderabbitai

coderabbitai Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Caution

Review failed

The pull request is closed.

Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

…CSS defaults

The stylesheets read --scroll-hint-sb-w, --scroll-hint-sb-h and
--action-box-offset, but only table-scroll.ts ever set them, so no
stylesheet defined them and LeanerCloud#745's custom-property lint flags the reads
as undefined. Declare :root defaults that equal the old var() fallbacks
(0px, and --action-box-height). The inline values the module sets on the
scroller and on <html> still win over :root, so rendering is unchanged.

Refs LeanerCloud#185

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE dff58b00df561d25ec34f70e692dc988722aaa7a

Blocking:

  • none
    Non-blocking:
  • frontend/src/lib/table-scroll.ts:190: the MutationObserver watches all of document.body (childList plus class/hidden attributes, subtree). Any class toggle anywhere in the app schedules a refresh that queries every and forces layout (getComputedStyle, scrollWidth). It is rAF-debounced and settles: no-op classList.toggle and strip.hidden writes do not re-trigger it. It is still app-wide work for a feature that is mostly about one table. Consider scoping the observer or checking only visible tabs.
  • frontend/src/styles/responsive.css:371: the fade mask goes on the scroller, and for most tables the scroller is the container, not the table (the fix(frontend): page-level horizontal scroll on Opportunities, Purchases and Admin #703 rule :where(div,section,...):has(> table)). Any sibling content inside that container is faded too. For example, #history-list holds the status-chip row and the table, so chips within 4rem of the right edge render at reduced opacity while the table overflows. When scrolled right, the left-edge checkbox column on Opportunities is also faded to 12%.
  • The Opportunities table's native scrollbar is always hidden (scrollbar-width:none). The strip only re-syncs on DOM mutation, resize or scroll. If the table width ever changes without one of those, there is briefly no visible horizontal scrollbar. I found no current trigger (no web fonts), so this is theoretical.
    Attacked: Read table-scroll.ts end to end and traced the observer/refresh/scroll-sync loops for self-retriggering and ping-pong. Checked whether the mask newly clips descendants: filter popovers portal to , and overflow-x:auto already clipped the in-cell tooltips, so nothing new is clipped. Checked that CSP is unaffected (CSSOM setProperty only, no style= markup). Checked the strip's a11y (aria-hidden, tabIndex -1). Reasoned through keepFocusClear ordering against Chromium's focus scroll-into-view. Confirmed the action box is always mounted and that the list is rebuilt with innerHTML, so the strip is recreated. The unit and e2e tests assert what they claim. "Closes" fits the issue's "and/or" expectations.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context) of the original head: APPROVE dff58b00

Blocking:

  • none
    Non-blocking:
  • frontend/src/lib/table-scroll.ts:190: the MutationObserver watches all of document.body (childList plus class/hidden attributes, subtree). Any class toggle anywhere in the app schedules a refresh that queries every and forces layout (getComputedStyle, scrollWidth). It is rAF-debounced and settles: no-op classList.toggle and strip.hidden writes do not re-trigger it. It is still app-wide work for a feature that is mostly about one table. Consider scoping the observer or checking only visible tabs.
  • frontend/src/styles/responsive.css:371: the fade mask goes on the scroller, and for most tables the scroller is the container, not the table (the fix(frontend): page-level horizontal scroll on Opportunities, Purchases and Admin #703 rule :where(div,section,...):has(> table)). Any sibling content inside that container is faded too. For example, #history-list holds the status-chip row and the table, so chips within 4rem of the right edge render at reduced opacity while the table overflows. When scrolled right, the left-edge checkbox column on Opportunities is also faded to 12%.
  • The Opportunities table's native scrollbar is always hidden (scrollbar-width:none). The strip only re-syncs on DOM mutation, resize or scroll. If the table width ever changes without one of those, there is briefly no visible horizontal scrollbar. I found no current trigger (no web fonts), so this is theoretical.
    Attacked: Read table-scroll.ts end to end and traced the observer/refresh/scroll-sync loops for self-retriggering and ping-pong. Checked whether the mask newly clips descendants: filter popovers portal to , and overflow-x:auto already clipped the in-cell tooltips, so nothing new is clipped. Checked that CSP is unaffected (CSSOM setProperty only, no style= markup). Checked the strip's a11y (aria-hidden, tabIndex -1). Reasoned through keepFocusClear ordering against Chromium's focus scroll-into-view. Confirmed the action box is always mounted and that the list is rebuilt with innerHTML, so the strip is recreated. The unit and e2e tests assert what they claim. "Closes" fits the issue's "and/or" expectations.

  • Follow-up commit 6f8dd286 (defensive, from combined-tree testing). No stylesheet defined --scroll-hint-sb-w, --scroll-hint-sb-h or --action-box-offset: only table-scroll.ts sets them. #745's new custom-property test therefore fails once both PRs are merged. I hit this in the combined tree of this merge wave, where jest failed on components.css and responsive.css.

    • 6f8dd286 declares :root defaults equal to the old fallbacks: 0px, 0px and var(--action-box-height). The inline values the module sets still override :root, so rendering is unchanged.
    • A new test checks that every property the module sets has a CSS default.
    • fix(frontend): undefined CSS custom properties and inline-styled MFA panels #745's test passes 111/111 on top of this branch, against 109/111 before.
    • Checks at 6f8dd286: jest 3505 passed, Playwright 161 passed, and lint, typecheck, build and git-secrets all pass. A fresh re-review of the new head follows.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE 6f8dd286e7b705ab87928641f411cd6d528cad15

Blocking:

  • none
    Non-blocking:
  • Carried over from the first review: the body-wide MutationObserver (table-scroll.ts:190) refreshes every table on any class/hidden change. The fade mask can dim sibling content inside the scroller, for example the #history-list status chips.
  • 185.md "What changed" says "one line changed in components.css". The diff now changes two lines (813 and 866).
    Attacked: Checked that the new :root defaults keep rendering the same. --action-box-height is defined only on :root, so resolving --action-box-offset: var(--action-box-height) at :root equals the old per-element fallback. The inline setProperty on and on the scroller still beats the :root rule, and custom-property inheritance into nested scrollers is the same as before. Verified that #recommendations-list is excluded from the fix(frontend): page-level horizontal scroll on Opportunities, Purchases and Admin #703 container-scroller rule, so the sticky strip's scroll container is still the viewport. Re-read table-scroll.ts for observer self-retriggering (setVar and the toggle guards stop it) and CSP (CSSOM only). The new test's regex really requires a :root declaration for each setVar name it finds.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Head e30a621f is the approved 6f8dd286 plus a conflict-free merge of main, done to get a fresh CI run. Its added and removed lines against main are identical to the reviewed diff (checked line by line), so the approval above carries over to e30a621f.

@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Local verification at e30a621f merged onto current main (8e823c78; merge-tree 6aacf212; Linux, Node, headless Chromium):

  • This branch's own checks passed at this head: lint, typecheck, build, the full jest suite, the full Playwright e2e suite, and the git-secrets checks (see Verification in the PR body).
  • The combined tree of every UI PR in this merge wave (tree dc090cc2, main plus 17 PRs) passes npm ci, lint, typecheck, build, jest (1 skipped, 4097 passed, 4098 total), and the full Playwright suite (232 passed).
  • CI is green on e30a621f (CI Success, Run pre-commit hooks).
  • The independent review above approves this exact head.
  • Nothing was applied to any cloud.

@IAmJSD
IAmJSD marked this pull request as ready for review October 24, 2026 18:52
@IAmJSD
IAmJSD merged commit 11b372a into LeanerCloud:main Oct 24, 2026
29 checks passed
@IAmJSD

IAmJSD commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator Author

Marked ready and merged by IAmJSD's agent, at IAmJSD's request (IAmJSD reviewed the before/after screenshots; cristim is traveling), after the independent review and local verification above, all at e30a621f.

IAmJSD added a commit to IAmJSD/cloud-commitments-platform that referenced this pull request Oct 24, 2026
No textual conflicts. Checked main's changes next to this branch's:
LeanerCloud#1344 moved the YTD sparkline into loadYtdSparkline(), which looks up
.kpi-tile-spark[data-spark-key="ytd"]; lib/kpi-tile.ts still renders
that slot for the YTD tile. CSS added by LeanerCloud#1336/LeanerCloud#1332/LeanerCloud#1388 (sticky
section nav z-index 5, table scroll shadows z-index 9, sort buttons)
sits below the dialog z-index ladder and does not touch card or dialog
surfaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(frontend): wide data tables clip right-edge columns under sticky header + action bar with no scroll affordance

1 participant