diff --git a/frontend/src/__tests__/index.test.ts b/frontend/src/__tests__/index.test.ts index 015724152..202081a99 100644 --- a/frontend/src/__tests__/index.test.ts +++ b/frontend/src/__tests__/index.test.ts @@ -31,12 +31,17 @@ jest.mock('../auth', () => ({ logout: jest.fn() })); +jest.mock('../lib/table-scroll', () => ({ + initTableScroll: jest.fn() +})); + import * as app from '../app'; import * as plans from '../plans'; import * as recommendations from '../recommendations'; import * as history from '../history'; import * as settings from '../settings'; import * as auth from '../auth'; +import * as tableScroll from '../lib/table-scroll'; describe('Index Module', () => { beforeEach(() => { @@ -118,4 +123,10 @@ describe('Index Module', () => { resolve(undefined); }, 10)); }); + + test('wires the wide-table scroll affordances on DOMContentLoaded (#185)', () => { + require('../index'); + document.dispatchEvent(new Event('DOMContentLoaded')); + expect(tableScroll.initTableScroll).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/__tests__/table-scroll.test.ts b/frontend/src/__tests__/table-scroll.test.ts new file mode 100644 index 000000000..c03faf006 --- /dev/null +++ b/frontend/src/__tests__/table-scroll.test.ts @@ -0,0 +1,265 @@ +/** + * Wide-table scroll affordance tests (issue #185). + * + * jsdom has no layout engine, so each test stubs the size and scroll + * properties the module reads. The real rendering (fade, sticky strip above + * the action box) is covered by tests-e2e/table-scroll-affordance.spec.ts. + */ + +import fs from 'fs'; +import path from 'path'; +import { + HINT_LEFT, + HINT_RIGHT, + STRIP_CLASS, + handleScroll, + keepFocusClear, + refreshTableScroll, + scrollerFor, + syncScrollStrip, + updateScrollHint, +} from '../lib/table-scroll'; + +interface Size { + scrollWidth?: number; + clientWidth?: number; + offsetWidth?: number; + offsetHeight?: number; + clientHeight?: number; +} + +function stubSize(el: HTMLElement, size: Size): void { + for (const [key, value] of Object.entries(size)) { + Object.defineProperty(el, key, { configurable: true, value }); + } +} + +function hints(el: HTMLElement): string[] { + return [HINT_LEFT, HINT_RIGHT].filter((c) => el.classList.contains(c)); +} + +function mountRecommendationsTable(): HTMLTableElement { + document.body.innerHTML = '
x
'; + const table = document.querySelector('table')!; + table.style.overflowX = 'auto'; + return table; +} + +describe('scrollerFor', () => { + afterEach(() => { document.body.innerHTML = ''; }); + + test('returns the table when the table itself scrolls', () => { + document.body.innerHTML = '
'; + const table = document.querySelector('table')!; + table.style.overflowX = 'auto'; + expect(scrollerFor(table)).toBe(table); + }); + + test('returns the direct container when it scrolls', () => { + document.body.innerHTML = '
'; + const table = document.querySelector('table')!; + expect(scrollerFor(table)).toBe(table.parentElement); + }); + + test('returns null when neither scrolls', () => { + document.body.innerHTML = '
'; + expect(scrollerFor(document.querySelector('table')!)).toBeNull(); + }); +}); + +describe('updateScrollHint', () => { + test('a table that fits gets no hint', () => { + const el = document.createElement('div'); + stubSize(el, { scrollWidth: 500, clientWidth: 500 }); + updateScrollHint(el); + expect(hints(el)).toEqual([]); + }); + + test('at the left edge only the right side is hinted', () => { + const el = document.createElement('div'); + stubSize(el, { scrollWidth: 1400, clientWidth: 1000, offsetWidth: 1000, offsetHeight: 300, clientHeight: 288 }); + updateScrollHint(el); + expect(hints(el)).toEqual([HINT_RIGHT]); + // A horizontal scrollbar's height is exported so the fade skips it. + expect(el.style.getPropertyValue('--scroll-hint-sb-h')).toBe('12px'); + expect(el.style.getPropertyValue('--scroll-hint-sb-w')).toBe('0px'); + }); + + test('in the middle both sides are hinted, at the end only the left', () => { + const el = document.createElement('div'); + stubSize(el, { scrollWidth: 1400, clientWidth: 1000 }); + el.scrollLeft = 200; + updateScrollHint(el); + expect(hints(el)).toEqual([HINT_LEFT, HINT_RIGHT]); + el.scrollLeft = 400; + updateScrollHint(el); + expect(hints(el)).toEqual([HINT_LEFT]); + }); + + test('hints are dropped once the table fits again', () => { + const el = document.createElement('div'); + stubSize(el, { scrollWidth: 1400, clientWidth: 1000 }); + updateScrollHint(el); + stubSize(el, { scrollWidth: 1400, clientWidth: 1400 }); + updateScrollHint(el); + expect(hints(el)).toEqual([]); + }); +}); + +describe('syncScrollStrip', () => { + afterEach(() => { document.body.innerHTML = ''; }); + + test('adds one aria-hidden strip right after the table, spanning the same scroll range', () => { + const table = mountRecommendationsTable(); + stubSize(table, { scrollWidth: 1400, clientWidth: 1000 }); + const strip = syncScrollStrip(table); + stubSize(strip, { clientWidth: 1000 }); + syncScrollStrip(table); + + expect(table.nextElementSibling).toBe(strip); + expect(document.querySelectorAll(`.${STRIP_CLASS}`)).toHaveLength(1); + expect(strip.getAttribute('aria-hidden')).toBe('true'); + expect(strip.tabIndex).toBe(-1); + expect(strip.hidden).toBe(false); + expect((strip.firstElementChild as HTMLElement).style.width).toBe('1400px'); + }); + + test('hides the strip while the table fits', () => { + const table = mountRecommendationsTable(); + stubSize(table, { scrollWidth: 1000, clientWidth: 1000 }); + expect(syncScrollStrip(table).hidden).toBe(true); + }); + + test('scrolling the strip scrolls the table and the other way round', () => { + const table = mountRecommendationsTable(); + stubSize(table, { scrollWidth: 1400, clientWidth: 1000 }); + const strip = syncScrollStrip(table); + + strip.scrollLeft = 150; + handleScroll(strip); + expect(table.scrollLeft).toBe(150); + + table.scrollLeft = 320; + handleScroll(table); + expect(strip.scrollLeft).toBe(320); + }); + + test('a scroll on a hinted table refreshes its edge hints', () => { + const table = mountRecommendationsTable(); + stubSize(table, { scrollWidth: 1400, clientWidth: 1000 }); + updateScrollHint(table); + table.scrollLeft = 400; + handleScroll(table); + expect(hints(table)).toEqual([HINT_LEFT]); + }); +}); + +describe('refreshTableScroll', () => { + afterEach(() => { + document.body.innerHTML = ''; + document.documentElement.style.removeProperty('--action-box-offset'); + }); + + test('hints scrollers, adds the Opportunities strip and records the action box height', () => { + const table = mountRecommendationsTable(); + stubSize(table, { scrollWidth: 1400, clientWidth: 1000 }); + const box = document.createElement('div'); + box.id = 'recommendations-action-box'; + stubSize(box, { offsetHeight: 136 }); + document.body.appendChild(box); + + refreshTableScroll(); + + expect(hints(table)).toEqual([HINT_RIGHT]); + expect(table.nextElementSibling?.classList.contains(STRIP_CLASS)).toBe(true); + expect(document.documentElement.style.getPropertyValue('--action-box-offset')).toBe('136px'); + }); + + test('other tables get the hint but no strip', () => { + document.body.innerHTML = '
'; + const wrap = document.getElementById('history-list')!; + stubSize(wrap, { scrollWidth: 900, clientWidth: 400 }); + refreshTableScroll(); + expect(hints(wrap)).toEqual([HINT_RIGHT]); + expect(document.querySelector(`.${STRIP_CLASS}`)).toBeNull(); + }); + + test('a hidden action box leaves the last measured offset alone', () => { + document.documentElement.style.setProperty('--action-box-offset', '93px'); + const box = document.createElement('div'); + box.id = 'recommendations-action-box'; + stubSize(box, { offsetHeight: 0 }); + document.body.appendChild(box); + refreshTableScroll(); + expect(document.documentElement.style.getPropertyValue('--action-box-offset')).toBe('93px'); + }); +}); + +describe('keepFocusClear', () => { + let scrollBy: jest.SpyInstance; + + function rect(top: number, bottom: number): DOMRect { + return { top, bottom, left: 0, right: 0, width: 0, height: bottom - top, x: 0, y: top, toJSON: () => ({}) } as DOMRect; + } + + function mount(rowTop: number, keyboard = true): HTMLInputElement { + document.body.innerHTML = ` +
+
+
+
`; + const input = document.querySelector('input')!; + jest.spyOn(input, 'getBoundingClientRect').mockReturnValue(rect(rowTop, rowTop + 20)); + jest.spyOn(input, 'matches').mockImplementation((sel: string) => (sel === ':focus-visible' ? keyboard : false)); + jest.spyOn(document.querySelector('.app-topbar')!, 'getBoundingClientRect').mockReturnValue(rect(0, 56)); + jest.spyOn(document.querySelector('.table-scroll-strip')!, 'getBoundingClientRect').mockReturnValue(rect(706, 720)); + const box = document.getElementById('recommendations-action-box')!; + stubSize(box, { offsetHeight: 80 }); + jest.spyOn(box, 'getBoundingClientRect').mockReturnValue(rect(720, 800)); + return input; + } + + beforeEach(() => { + Object.defineProperty(window, 'innerHeight', { configurable: true, value: 800 }); + scrollBy = jest.spyOn(window, 'scrollBy').mockImplementation(() => undefined); + }); + afterEach(() => { + scrollBy.mockRestore(); + document.body.innerHTML = ''; + }); + + test('a row focused under the strip and action box is scrolled above them', () => { + keepFocusClear(mount(700)); + // Row bottom 720 vs strip top 706: 14px plus the 16px margin. + expect(scrollBy).toHaveBeenCalledWith(0, 30); + }); + + test('a row focused under the topbar is scrolled below it', () => { + keepFocusClear(mount(40)); + expect(scrollBy).toHaveBeenCalledWith(0, -32); + }); + + test('a row in the clear, a mouse click, or focus outside the list does nothing', () => { + keepFocusClear(mount(300)); + keepFocusClear(mount(700, false)); + const outside = document.createElement('button'); + document.body.appendChild(outside); + keepFocusClear(outside); + expect(scrollBy).not.toHaveBeenCalled(); + }); +}); + +test('every custom property the module sets has a stylesheet default', () => { + // The stylesheets read these with var(); a CSS default keeps them defined + // before the first measurement and for the custom-property lint (#745). + const src = path.join(__dirname, '..'); + const source = fs.readFileSync(path.join(src, 'lib', 'table-scroll.ts'), 'utf8'); + const set = [...source.matchAll(/setVar\([^,]+,\s*'(--[\w-]+)'/g)].map((m) => m[1]!); + expect(set.sort()).toEqual(['--action-box-offset', '--scroll-hint-sb-h', '--scroll-hint-sb-w']); + const stylesDir = path.join(src, 'styles'); + const css = fs.readdirSync(stylesDir) + .filter((f) => f.endsWith('.css')) + .map((f) => fs.readFileSync(path.join(stylesDir, f), 'utf8')) + .join('\n'); + expect(set.filter((name) => !new RegExp(`:root\\s*\\{[^}]*${name}\\s*:`).test(css))).toEqual([]); +}); diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 063400fff..ed0b6a58d 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -16,6 +16,7 @@ import { openCreateUserModal, closeUserModal } from './users/userModals'; import { openCreateGroupModal, closeGroupModal, addPermission, closeDuplicateGroupModal, saveDuplicateGroup, saveGroup } from './groups/groupModals'; import { wireGroupForm } from './groups/groupFormValidation'; import { initTopbarFilters } from './topbar-filters'; +import { initTableScroll } from './lib/table-scroll'; // Re-export for external use export { api, utils }; @@ -76,6 +77,9 @@ document.addEventListener('DOMContentLoaded', () => { // used to carry; sections subscribe to state.subscribeProvider / // subscribeAccount and reload themselves when the filter changes. initTopbarFilters(); + + // Edge fades and the Opportunities scrollbar strip for wide tables (#185). + initTableScroll(); }); // Initialize on page load diff --git a/frontend/src/lib/table-scroll.ts b/frontend/src/lib/table-scroll.ts new file mode 100644 index 000000000..d2121b1bb --- /dev/null +++ b/frontend/src/lib/table-scroll.ts @@ -0,0 +1,197 @@ +/** + * Wide-table scroll affordances (issue #185). + * + * Wide tables scroll sideways inside their own box (issue #703), but nothing + * told the user that columns were cut off on the right, and the Opportunities + * table's only horizontal scrollbar sat at the bottom of a page-tall table, + * under the sticky action box. This module adds three things: + * + * - A fade on whichever edge of a table scroller has hidden columns + * (`.scroll-hint-left` / `.scroll-hint-right`), for every table on every + * page, so a clipped column reads as "more this way". + * - For the Opportunities table, a scrollbar strip that sticks just above + * the action box while the table is on screen and mirrors the table's + * horizontal scroll position both ways. + * - `--action-box-offset`, the action box's real rendered height (it wraps + * to two or three lines on narrow screens), which the list padding and + * the strip use, plus a focus handler that scrolls a keyboard-focused + * Opportunities row out from under the sticky topbar or action box + * (browsers only scroll focus into view when it is off-screen, and a + * row under a sticky bar counts as on-screen). + * + * jsdom has no layout, so the jest tests stub the size properties; the + * Playwright spec tests-e2e/table-scroll-affordance.spec.ts covers the real + * rendering. + */ + +export const HINT_LEFT = 'scroll-hint-left'; +export const HINT_RIGHT = 'scroll-hint-right'; +export const STRIP_CLASS = 'table-scroll-strip'; +const STRIPPED_TABLE = '#recommendations-list > table'; +const ACTION_BOX_ID = 'recommendations-action-box'; + +function px(value: string): number { + return parseFloat(value) || 0; +} + +function setVar(el: HTMLElement, name: string, value: string): void { + if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value); +} + +/** The element that scrolls a table sideways: the table itself when it is a + * block scroller, else its direct container; null when neither scrolls. */ +export function scrollerFor(table: HTMLTableElement): HTMLElement | null { + for (const el of [table, table.parentElement]) { + if (!el) continue; + const overflowX = getComputedStyle(el).overflowX; + if (overflowX === 'auto' || overflowX === 'scroll') return el; + } + return null; +} + +/** Marks the edges of `el` that have content scrolled out of view. The + * scrollbar sizes are exported so the fade's CSS mask can leave the + * scrollbars themselves at full opacity. */ +export function updateScrollHint(el: HTMLElement): void { + const max = el.scrollWidth - el.clientWidth; + const left = max > 1 && el.scrollLeft > 1; + const right = max > 1 && el.scrollLeft < max - 1; + el.classList.toggle(HINT_LEFT, left); + el.classList.toggle(HINT_RIGHT, right); + if (!left && !right) return; + const cs = getComputedStyle(el); + const sbWidth = el.offsetWidth - el.clientWidth - px(cs.borderLeftWidth) - px(cs.borderRightWidth); + const sbHeight = el.offsetHeight - el.clientHeight - px(cs.borderTopWidth) - px(cs.borderBottomWidth); + setVar(el, '--scroll-hint-sb-w', `${Math.max(0, sbWidth)}px`); + setVar(el, '--scroll-hint-sb-h', `${Math.max(0, sbHeight)}px`); +} + +function stripAfter(table: HTMLElement): HTMLElement | null { + const next = table.nextElementSibling; + return next instanceof HTMLElement && next.classList.contains(STRIP_CLASS) ? next : null; +} + +/** Ensures the mirrored scrollbar strip right after `table` exists, spans the + * same scroll range and sits at the same position. Hidden while the table + * fits. The strip is aria-hidden: it duplicates the table's own scrolling, + * which keyboard and assistive-technology users already reach. */ +export function syncScrollStrip(table: HTMLElement): HTMLElement { + let strip = stripAfter(table); + if (!strip) { + strip = document.createElement('div'); + strip.className = STRIP_CLASS; + strip.setAttribute('aria-hidden', 'true'); + strip.tabIndex = -1; + strip.appendChild(document.createElement('div')); + table.after(strip); + } + const overflow = table.scrollWidth - table.clientWidth; + const hidden = overflow <= 1; + if (strip.hidden !== hidden) strip.hidden = hidden; + if (hidden) return strip; + const spacer = strip.firstElementChild as HTMLElement; + // Same scroll range as the table, whatever the strip's own width. + const width = `${strip.clientWidth + overflow}px`; + if (spacer.style.width !== width) spacer.style.width = width; + if (strip.scrollLeft !== table.scrollLeft) strip.scrollLeft = table.scrollLeft; + return strip; +} + +/** Re-measures every table scroller, the Opportunities strip and the action + * box offset. Cheap enough to run once per animation frame after a change. */ +export function refreshTableScroll(root: ParentNode = document): void { + root.querySelectorAll('table').forEach((table) => { + const scroller = scrollerFor(table); + if (scroller) updateScrollHint(scroller); + }); + root.querySelectorAll(STRIPPED_TABLE).forEach((table) => { + syncScrollStrip(table); + }); + const box = document.getElementById(ACTION_BOX_ID); + if (box && box.offsetHeight > 0) { + setVar(document.documentElement, '--action-box-offset', `${box.offsetHeight}px`); + } +} + +/** Keeps a strip and its table scrolled to the same position, and refreshes + * the edge fade of whatever scrolled. Exported for the unit tests. */ +export function handleScroll(target: EventTarget | null): void { + if (!(target instanceof HTMLElement)) return; + if (target.classList.contains(STRIP_CLASS)) { + const table = target.previousElementSibling; + if (table instanceof HTMLElement && table.scrollLeft !== target.scrollLeft) { + table.scrollLeft = target.scrollLeft; + } + return; + } + if (target.matches(STRIPPED_TABLE)) { + const strip = stripAfter(target); + if (strip && strip.scrollLeft !== target.scrollLeft) strip.scrollLeft = target.scrollLeft; + } + if (target.classList.contains(HINT_LEFT) || target.classList.contains(HINT_RIGHT)) { + updateScrollHint(target); + } +} + +const FOCUS_MARGIN = 16; + +/** A mouse click on a half-hidden checkbox should not jolt the page, so only + * keyboard focus (`:focus-visible`) is scrolled clear. */ +function isKeyboardFocus(el: HTMLElement): boolean { + try { + return el.matches(':focus-visible'); + } catch { + return true; // engines without :focus-visible support + } +} + +/** Scrolls the page so a focused control inside the Opportunities list is + * not hidden under the sticky topbar, the strip or the action box. + * Exported for the unit tests. */ +export function keepFocusClear(target: EventTarget | null): void { + if (!(target instanceof HTMLElement) || !target.closest('#recommendations-list')) return; + if (!isKeyboardFocus(target)) return; + const rect = target.getBoundingClientRect(); + const topbar = document.querySelector('.app-topbar'); + const top = topbar ? topbar.getBoundingClientRect().bottom : 0; + let bottom = window.innerHeight; + const box = document.getElementById(ACTION_BOX_ID); + if (box && box.offsetHeight > 0) bottom = Math.min(bottom, box.getBoundingClientRect().top); + const strip = document.querySelector(`${STRIPPED_TABLE} + .${STRIP_CLASS}`); + if (strip && !strip.hidden) bottom = Math.min(bottom, strip.getBoundingClientRect().top); + if (rect.bottom > bottom) { + window.scrollBy(0, rect.bottom - bottom + FOCUS_MARGIN); + } else if (rect.top < top) { + window.scrollBy(0, rect.top - top - FOCUS_MARGIN); + } +} + +let initialized = false; + +/** Wires the affordances for the app's lifetime: refresh after DOM changes + * (tables are re-rendered wholesale), on resize, and on every scroll. */ +export function initTableScroll(): void { + if (initialized) return; + initialized = true; + let queued = false; + const schedule = (): void => { + if (queued) return; + queued = true; + requestAnimationFrame(() => { + queued = false; + refreshTableScroll(); + }); + }; + document.addEventListener('scroll', (e) => handleScroll(e.target), { capture: true, passive: true }); + document.addEventListener('focusin', (e) => keepFocusClear(e.target)); + window.addEventListener('resize', schedule); + // class/hidden catch tab switches and the sidebar collapsing, both of + // which change a table's width without touching its children. + new MutationObserver(schedule).observe(document.body, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'hidden'], + }); + schedule(); +} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 7359b1c44..9b3ff7ee9 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -810,7 +810,7 @@ th .column-filter-btn.active { * up the design tokens so colors + spacing + radii sit in the same * visual family as the rest of the action-center reskin. Drawer is * tracked as a deferred sub-task in #340. */ -:root { --action-box-height: 80px; } +:root { --action-box-height: 80px; --action-box-offset: var(--action-box-height); } .recommendations-action-box { position: sticky; bottom: 0; @@ -863,7 +863,7 @@ th .column-filter-btn.active { gap: 0.4rem; font-size: 0.9em; } -#recommendations-list { padding-bottom: var(--action-box-height); } +#recommendations-list { padding-bottom: var(--action-box-offset, var(--action-box-height)); } tr.recommendation-row { cursor: pointer; diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index 37d04eb6a..9f5d8b20e 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -355,3 +355,74 @@ body.sidebar-open { max-width: 100%; overflow-x: auto; } + +/* Wide-table scroll affordances (issue #185); lib/table-scroll.ts sets the + * classes and variables. A scroller with columns cut off on one side fades + * that edge out, so the clipped column reads as "more this way". The fade is + * a mask on the scroller's own box, so cell and row backgrounds cannot cover + * it; the extra mask layers keep any scrollbar at full opacity. */ +:root { --scroll-hint-sb-w: 0px; --scroll-hint-sb-h: 0px; } +.scroll-hint-left, +.scroll-hint-right { + --scroll-hint-l: #000; + --scroll-hint-r: #000; + --scroll-hint-fade: linear-gradient(to right, var(--scroll-hint-l), #000 4rem, #000 calc(100% - 4rem), var(--scroll-hint-r)); + -webkit-mask-image: var(--scroll-hint-fade), linear-gradient(#000, #000), linear-gradient(#000, #000); + mask-image: var(--scroll-hint-fade), linear-gradient(#000, #000), linear-gradient(#000, #000); + -webkit-mask-size: calc(100% - var(--scroll-hint-sb-w, 0px)) calc(100% - var(--scroll-hint-sb-h, 0px)), var(--scroll-hint-sb-w, 0px) 100%, 100% var(--scroll-hint-sb-h, 0px); + mask-size: calc(100% - var(--scroll-hint-sb-w, 0px)) calc(100% - var(--scroll-hint-sb-h, 0px)), var(--scroll-hint-sb-w, 0px) 100%, 100% var(--scroll-hint-sb-h, 0px); + -webkit-mask-position: 0 0, 100% 0, 0 100%; + mask-position: 0 0, 100% 0, 0 100%; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; +} +.scroll-hint-left { --scroll-hint-l: rgb(0 0 0 / 0.12); } +.scroll-hint-right { --scroll-hint-r: rgb(0 0 0 / 0.12); } + +/* The Opportunities table is page-tall, so its own horizontal scrollbar sat + * off-screen under the action box. The strip after it mirrors that scrollbar + * and sticks just above the action box while the table is on screen; the + * table's own bar is hidden so the two never show at once (touch, trackpad + * and Shift+wheel still scroll the table directly). The ::-webkit-scrollbar + * rules keep the strip's bar visible where the OS uses overlay scrollbars; + * Chromium ignores them once scrollbar-color is set, hence the @supports. */ +#recommendations-list > table { + scrollbar-width: none; +} +#recommendations-list > table::-webkit-scrollbar { + display: none; +} +.table-scroll-strip { + position: sticky; + bottom: var(--action-box-offset, var(--action-box-height)); + z-index: 9; + overflow-x: scroll; + overflow-y: hidden; + background: var(--cudly-surface); + border-top: 1px solid var(--cudly-border); +} +.table-scroll-strip[hidden] { + display: none; +} +.table-scroll-strip > div { + height: 1px; +} +.table-scroll-strip::-webkit-scrollbar { + height: 12px; +} +.table-scroll-strip::-webkit-scrollbar-track { + background: var(--cudly-surface-muted); +} +.table-scroll-strip::-webkit-scrollbar-thumb { + background: var(--cudly-border-strong); + border: 2px solid var(--cudly-surface-muted); + border-radius: var(--cudly-r-full); +} +.table-scroll-strip::-webkit-scrollbar-thumb:hover { + background: var(--cudly-text-subtle); +} +@supports not selector(::-webkit-scrollbar) { + .table-scroll-strip { + scrollbar-color: var(--cudly-border-strong) var(--cudly-surface-muted); + } +} diff --git a/frontend/tests-e2e/table-scroll-affordance.spec.ts b/frontend/tests-e2e/table-scroll-affordance.spec.ts new file mode 100644 index 000000000..52e59cb4b --- /dev/null +++ b/frontend/tests-e2e/table-scroll-affordance.spec.ts @@ -0,0 +1,115 @@ +/** + * Wide-table scroll affordances (issue #185). + * + * The Opportunities table is wider than the content area at common laptop + * widths, and it is page-tall, so its own horizontal scrollbar used to sit + * off-screen under the sticky action box with nothing showing that columns + * were cut off. This checks, in a real browser, that the clipped edge fades, + * that a scrollbar strip sits on screen just above the action box and stays + * in step with the table, and that keyboard focus keeps rows clear of the + * action box. + */ + +import { test, expect, type Page } from '@playwright/test'; +import { mockApi, seedAuth, RECS } from './fixtures/recs'; + +// 40 rows so the table runs well past the bottom of the viewport. +const MANY = Array.from({ length: 40 }, (_, i) => ({ + ...RECS[i % RECS.length]!, + id: `wide-${i}`, + region: i % 2 ? 'ap-southeast-2' : RECS[i % RECS.length]!.region, + count: i + 1, +})); + +async function openOpportunities(page: Page, width: number): Promise { + await page.setViewportSize({ width, height: 900 }); + await seedAuth(page); + await mockApi(page); + await page.route( + (url) => url.pathname === '/api/recommendations', + (route) => (route.request().method() === 'GET' + ? route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + recommendations: MANY, + summary: { total_recommendations: MANY.length, total_upfront_cost: 0, potential_monthly_savings: 0, avg_payback_months: 6 }, + }), + }) + : route.fallback()), + ); + await page.goto('/opportunities'); + await page.waitForLoadState('networkidle'); +} + +const table = '#recommendations-list > table'; +const strip = '#recommendations-list > .table-scroll-strip'; + +for (const width of [1280, 390]) { + test(`opportunities table shows where its hidden columns are at ${width}px`, async ({ page }) => { + await openOpportunities(page, width); + + const tbl = page.locator(table); + await expect(tbl).toHaveClass(/scroll-hint-right/); + await expect(tbl).not.toHaveClass(/scroll-hint-left/); + + // The strip is on screen at the top of the page, right above the action box. + const bar = page.locator(strip); + await expect(bar).toBeVisible(); + const barBox = (await bar.boundingBox())!; + const actionBox = (await page.locator('#recommendations-action-box').boundingBox())!; + expect(barBox.y + barBox.height).toBeLessThanOrEqual(actionBox.y + 1); + expect(barBox.y + barBox.height).toBeGreaterThan(actionBox.y - 2); + expect(barBox.y).toBeGreaterThan(0); + + // Scrolling the strip scrolls the table, and both edges fade mid-way. + await bar.evaluate((el) => { el.scrollLeft = 120; }); + await expect.poll(() => tbl.evaluate((el) => el.scrollLeft)).toBe(120); + await expect(tbl).toHaveClass(/scroll-hint-left/); + await expect(tbl).toHaveClass(/scroll-hint-right/); + + // Scrolling the table (trackpad, Shift+wheel) moves the strip too; at the + // far end only the left edge fades. + await tbl.evaluate((el) => { el.scrollLeft = el.scrollWidth; }); + const end = await tbl.evaluate((el) => el.scrollLeft); + await expect.poll(() => bar.evaluate((el) => el.scrollLeft)).toBe(end); + await expect(tbl).not.toHaveClass(/scroll-hint-right/); + + // Still no page-level horizontal scroll (#703). + expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(0); + }); +} + +test('keyboard focus scrolls a row clear of the action box', async ({ page }) => { + await openOpportunities(page, 1280); + const actionTop = async (): Promise => (await page.locator('#recommendations-action-box').boundingBox())!.y; + + // A row checkbox that starts underneath the action box. + const boxes = page.locator(`${table} tbody input[type="checkbox"]`); + const top = await actionTop(); + let target = -1; + for (let i = 0; i < await boxes.count(); i++) { + const b = await boxes.nth(i).boundingBox(); + if (b && b.y > top) { target = i; break; } + } + expect(target).toBeGreaterThan(0); + + await boxes.nth(target - 1).focus(); + await page.keyboard.press('Tab'); + // Tab may land on the row's Plan button first; either way the focused + // control must end up above the strip and the action box (the page + // scrolls smoothly, hence the poll). + await expect.poll(async () => { + const focusedBottom = await page.evaluate(() => document.activeElement!.getBoundingClientRect().bottom); + const stripTop = (await page.locator(strip).boundingBox())!.y; + return focusedBottom <= Math.min(stripTop, await actionTop()); + }).toBe(true); +}); + +test('a table that fits gets no fade and no strip', async ({ page }) => { + await openOpportunities(page, 1920); + const tbl = page.locator(table); + expect(await tbl.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); + await expect(page.locator('.scroll-hint-left, .scroll-hint-right')).toHaveCount(0); + await expect(page.locator(strip)).toBeHidden(); +});