diff --git a/frontend/src/styles/charts.css b/frontend/src/styles/charts.css index e62f9e63..f63828ea 100644 --- a/frontend/src/styles/charts.css +++ b/frontend/src/styles/charts.css @@ -9,6 +9,9 @@ } .chart-section canvas { + /* Chart.js sizes the canvas from its parent; without this a canvas that + * was drawn wider than the card keeps the page wide (#703). */ + max-width: 100%; max-height: 300px; min-height: 280px; } diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index d9f1fe6f..f51af1a5 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -702,6 +702,7 @@ th .column-filter-btn.active { /* Filter-status bar (Clear-filters badge + aria-live count) above the table. */ .recommendations-filter-status { display: flex; + flex-wrap: wrap; align-items: center; gap: 0.75rem; padding: 0.4rem 0; @@ -749,7 +750,8 @@ th .column-filter-btn.active { .recommendations-action-box { position: sticky; bottom: 0; - height: var(--action-box-height); + min-height: var(--action-box-height); + flex-wrap: wrap; background: var(--cudly-surface); border-top: 1px solid var(--cudly-border); box-shadow: var(--cudly-shadow-md); @@ -1235,8 +1237,10 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .info-icon .tooltip-text { - visibility: hidden; - opacity: 0; + /* display:none, not visibility:hidden: a hidden absolutely positioned + * box still extends the scrollable area and widens the page (#703). */ + display: none; + max-width: calc(100vw - 2rem); position: absolute; z-index: 1000; bottom: 125%; @@ -1252,7 +1256,6 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { line-height: 1.4; text-align: left; box-shadow: 0 2px 8px rgba(0,0,0,0.2); - transition: opacity 0.2s, visibility 0.2s; white-space: normal; } @@ -1268,8 +1271,7 @@ tr.rec-variant-row:hover td:not(.checkbox-col) { } .info-icon:hover .tooltip-text { - visibility: visible; - opacity: 1; + display: block; } /* Role badge */ diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css index 4a17ab04..0fbe0ee6 100644 --- a/frontend/src/styles/responsive.css +++ b/frontend/src/styles/responsive.css @@ -121,11 +121,6 @@ body.sidebar-open { width: 100%; } - table { - display: block; - overflow-x: auto; - } - .modal-content { width: 95%; } @@ -352,3 +347,18 @@ body.sidebar-open { white-space: nowrap; } } + +/* A wide table scrolls inside its container instead of widening the page + * (issue #703). The container, not the table, takes the overflow so a short + * table keeps display:table and fills the card. The recommendations list is + * excluded: its sticky action box must keep sticking to the viewport, so its + * table scrolls itself. */ +:where(div, section, fieldset, form, article):has(> table):not(#recommendations-list) { + overflow-x: auto; +} + +#recommendations-list > table { + display: block; + max-width: 100%; + overflow-x: auto; +} diff --git a/frontend/src/styles/settings.css b/frontend/src/styles/settings.css index 85e573c0..ddae897a 100644 --- a/frontend/src/styles/settings.css +++ b/frontend/src/styles/settings.css @@ -3,11 +3,13 @@ .settings-tabs, .inventory-subnav { display: flex; + overflow-x: auto; border-bottom: 2px solid var(--cudly-border); margin-bottom: 1.5rem; } .sub-tab-btn { + flex-shrink: 0; padding: 0.65rem 1.25rem; background: none; border: none; @@ -66,6 +68,8 @@ } .settings-category { + /* A fieldset defaults to min-width: min-content, which widens the page. */ + min-width: 0; border: 1px solid var(--cudly-border); border-radius: 8px; padding: 1.5rem; @@ -117,6 +121,13 @@ display: flex; align-items: center; gap: 1rem; + /* A select sizes to its longest option; keep it inside the row. */ + max-width: 100%; + min-width: 0; +} + +.setting-input select { + max-width: 100%; } .setting-input input[type="text"], @@ -451,13 +462,15 @@ select.dirty { } .group-card-header { display: flex; + flex-wrap: wrap; + gap: 0.5rem; justify-content: space-between; align-items: flex-start; margin-bottom: 0.75rem; } .group-card-header h4 { margin: 0 0 0.25rem; } .group-card-header .group-description { font-size: 0.85rem; color: var(--cudly-text-muted); margin: 0; } -.group-card-actions { display: flex; gap: 0.5rem; flex-shrink: 0; align-items: center; } +.group-card-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; min-width: 0; align-items: center; } .permission-badge { display: inline-block; font-size: 0.8rem; @@ -470,6 +483,9 @@ select.dirty { } .member-pill { display: inline-block; + max-width: 100%; + box-sizing: border-box; + overflow-wrap: anywhere; font-size: 0.8rem; padding: 0.15rem 0.4rem; border-radius: 10px; @@ -606,3 +622,9 @@ select.dirty { tr.override-disabled { opacity: 0.55; } + +/* The matrix scrolls sideways; its absolutely positioned .sr-only cell labels + * need this scroller as their containing block or they extend the page. */ +#permission-matrix { + position: relative; +} diff --git a/frontend/src/users/permissionMatrix.ts b/frontend/src/users/permissionMatrix.ts index 309a007a..8f4f7eec 100644 --- a/frontend/src/users/permissionMatrix.ts +++ b/frontend/src/users/permissionMatrix.ts @@ -18,6 +18,9 @@ const ACTIONS = ['view', 'create', 'update', 'delete', 'execute', 'approve', 'ad */ function renderEmptyState(container: HTMLElement, message: string): void { container.textContent = ''; + container.removeAttribute('tabindex'); + container.removeAttribute('role'); + container.removeAttribute('aria-label'); const p = document.createElement('p'); p.className = 'empty-state'; p.textContent = message; @@ -74,4 +77,9 @@ export function renderPermissionMatrix(groups: APIGroup[], container: HTMLElemen `; + // The matrix scrolls sideways on narrow screens and has no focusable + // content of its own, so the scroll region must be keyboard-reachable. + container.setAttribute('tabindex', '0'); + container.setAttribute('role', 'region'); + container.setAttribute('aria-label', 'Permission matrix'); } diff --git a/frontend/tests-e2e/page-overflow.spec.ts b/frontend/tests-e2e/page-overflow.spec.ts new file mode 100644 index 00000000..b9cc0edb --- /dev/null +++ b/frontend/tests-e2e/page-overflow.spec.ts @@ -0,0 +1,62 @@ +/** + * Page-level horizontal scroll guard (issue #703). + * + * A wide table, a fieldset's min-content width, a hidden-but-laid-out + * tooltip or a non-wrapping toolbar each push the document wider than the + * viewport, so the whole page scrolls sideways. jsdom has no layout, so this + * runs in Chromium and compares the document's scroll width to the viewport. + */ + +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const GROUPS = ['Administrators', 'Purchasers', 'Read-only finance auditors', 'Platform engineering leads'].map((name, i) => ({ + id: `00000000-0000-5000-8000-00000000000${i + 1}`, + name, + description: `${name}: a deliberately long description so the group card header has to share its row with the action buttons`, + permissions: [{ action: i === 0 ? 'admin' : 'view', resource: i === 0 ? '*' : 'recommendations' }], +})); +const USERS = [1, 2, 3].map((n) => ({ + id: `user-${n}`, + email: `long.address.for.user.number.${n}@subdomain.example-company.com`, + groups: GROUPS.map((g) => g.id), + mfa_enabled: n % 2 === 0, + created_at: '2026-07-01T00:00:00Z', +})); + +const PAGES = [ + '/home', + '/opportunities', + '/plans', + '/purchases', + '/inventory', + '/admin/general', + '/admin/purchasing', + '/admin/accounts', + '/admin/users', +]; +const WIDTHS = [1280, 768, 390]; + +for (const width of WIDTHS) { + for (const path of PAGES) { + test(`${path} does not scroll horizontally at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await seedAuth(page); + await mockApi(page); + // Populated users and groups: empty lists hide the group cards, the + // permission matrix and the long user rows that overflow on phones. + await page.route('**/api/users', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ users: USERS }) }), + ); + await page.route('**/api/groups', (route) => + route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ groups: GROUPS }) }), + ); + await page.goto(path); + await page.waitForLoadState('networkidle'); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - window.innerWidth, + ); + expect(overflow).toBeLessThanOrEqual(0); + }); + } +}