Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/styles/charts.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
14 changes: 8 additions & 6 deletions frontend/src/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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%;
Expand All @@ -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;
}

Expand All @@ -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 */
Expand Down
20 changes: 15 additions & 5 deletions frontend/src/styles/responsive.css
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,6 @@ body.sidebar-open {
width: 100%;
}

table {
display: block;
overflow-x: auto;
}

.modal-content {
width: 95%;
}
Expand Down Expand Up @@ -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;
}
24 changes: 23 additions & 1 deletion frontend/src/styles/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
8 changes: 8 additions & 0 deletions frontend/src/users/permissionMatrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,4 +77,9 @@ export function renderPermissionMatrix(groups: APIGroup[], container: HTMLElemen
</tbody>
</table>
`;
// 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');
}
62 changes: 62 additions & 0 deletions frontend/tests-e2e/page-overflow.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
Loading