#301: stop /settings scrolling horizontally at 320px
WCAG 2.1 SC 1.4.10 asks for no two-dimensional scrolling down to 320px, which is also what 400% zoom on a 1280px screen produces. The layout skeleton was already hardened for this in #165; the overflow came from content inside the sections. - The sessions table cannot shrink below its min-content width — four columns, one of them the full user-agent string. It now scrolls inside its own container rather than pushing the page. The container is focusable with a role and a name, because a scroll area that only a mouse can reach trades one barrier for another. - `.settings-checkbox` rows may wrap. The accent swatches have a fixed size and cannot shrink, so an unwrappable row set a floor for the whole page width. Adds a reflow guard to the a11y pack. axe does not cover 1.4.10 — the criterion is not derivable from the DOM — so this is a separate check, and it names the overflowing elements when it trips instead of only reporting that something overflows.
This commit is contained in:
parent
5a4a99196e
commit
e48b9dd7df
@ -104,3 +104,55 @@ for (const scheme of SCHEMES) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflow (WCAG 2.1 SC 1.4.10, issue #301): bei 320 px CSS-Breite — was 400 %
|
||||||
|
* Zoom auf 1280 px entspricht — darf die Seite nicht seitenweit horizontal
|
||||||
|
* scrollen. axe prüft das NICHT, das Kriterium ist nicht maschinell aus dem
|
||||||
|
* DOM ableitbar; deshalb ein eigener Zaun.
|
||||||
|
*
|
||||||
|
* Schlägt er an, nennt er die überstehenden Elemente. Ohne diese Diagnose
|
||||||
|
* weiß man nur DASS es überläuft und muss im Browser bisektieren.
|
||||||
|
*/
|
||||||
|
const NARROW = { width: 320, height: 800 };
|
||||||
|
|
||||||
|
async function expectNoHorizontalScroll(page: Page, label: string): Promise<void> {
|
||||||
|
const report = await page.evaluate(() => {
|
||||||
|
const doc = document.documentElement;
|
||||||
|
const limit = doc.clientWidth;
|
||||||
|
const offenders: string[] = [];
|
||||||
|
for (const el of Array.from(document.querySelectorAll<HTMLElement>('body *'))) {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
// 1 px Toleranz gegen Subpixel-Rundung.
|
||||||
|
if (rect.width > 0 && rect.right > limit + 1) {
|
||||||
|
const cls =
|
||||||
|
el.className && typeof el.className === 'string'
|
||||||
|
? `.${el.className.trim().split(/\s+/).join('.')}`
|
||||||
|
: '';
|
||||||
|
offenders.push(
|
||||||
|
`${el.tagName.toLowerCase()}${cls} (right=${Math.round(rect.right)}, width=${Math.round(rect.width)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { scrollWidth: doc.scrollWidth, clientWidth: limit, offenders: offenders.slice(0, 12) };
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
{ overflowBy: report.scrollWidth - report.clientWidth, offenders: report.offenders },
|
||||||
|
`${label}: horizontaler Überlauf bei 320 px`,
|
||||||
|
).toEqual({ overflowBy: 0, offenders: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('reflow at 320px', () => {
|
||||||
|
test('user settings do not scroll horizontally at 320px', async ({ browser }) => {
|
||||||
|
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.setViewportSize(NARROW);
|
||||||
|
await page.goto('/settings');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
// Die Sitzungstabelle rendert asynchron und ist der breiteste Inhalt —
|
||||||
|
// ohne sie misst der Zaun eine halb aufgebaute Seite.
|
||||||
|
await page.locator('.table tbody tr').first().waitFor();
|
||||||
|
await expectNoHorizontalScroll(page, '/settings');
|
||||||
|
await context.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -385,41 +385,56 @@ function SessionsSection(): React.JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h2>{t('settings:sessions.title')}</h2>
|
<h2>{t('settings:sessions.title')}</h2>
|
||||||
<table className="table">
|
{/* A table cannot shrink below its min-content width, so the user-agent
|
||||||
<thead>
|
column pushed the whole page into horizontal scrolling at 320px
|
||||||
<tr>
|
(issue #301, WCAG 1.4.10). It scrolls inside its own box instead —
|
||||||
<th>{t('settings:sessions.device')}</th>
|
the content stays reachable, which `overflow: hidden` would not. */}
|
||||||
<th>{t('settings:sessions.created')}</th>
|
<div
|
||||||
<th>{t('settings:sessions.lastSeen')}</th>
|
className="table-scroll"
|
||||||
<th>
|
// A scroll container is only operable by keyboard once it is
|
||||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
// focusable; role+name keep it from being an unlabelled stop.
|
||||||
</th>
|
tabIndex={0}
|
||||||
</tr>
|
role="region"
|
||||||
</thead>
|
aria-label={t('settings:sessions.title')}
|
||||||
<tbody>
|
>
|
||||||
{(sessions.data ?? []).map((session) => (
|
<table className="table">
|
||||||
<tr key={session.id}>
|
<thead>
|
||||||
<td>
|
<tr>
|
||||||
{session.userAgent ?? '—'}
|
<th>{t('settings:sessions.device')}</th>
|
||||||
{session.current && <span className="badge">{t('settings:sessions.current')}</span>}
|
<th>{t('settings:sessions.created')}</th>
|
||||||
</td>
|
<th>{t('settings:sessions.lastSeen')}</th>
|
||||||
<td>{formatTime(session.createdAt)}</td>
|
<th>
|
||||||
<td>{formatTime(session.lastSeenAt)}</td>
|
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||||
<td>
|
</th>
|
||||||
{!session.current && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="linklike"
|
|
||||||
onClick={() => revoke.mutate(session.id)}
|
|
||||||
>
|
|
||||||
{t('settings:sessions.revoke')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{(sessions.data ?? []).map((session) => (
|
||||||
|
<tr key={session.id}>
|
||||||
|
<td>
|
||||||
|
{session.userAgent ?? '—'}
|
||||||
|
{session.current && (
|
||||||
|
<span className="badge">{t('settings:sessions.current')}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{formatTime(session.createdAt)}</td>
|
||||||
|
<td>{formatTime(session.lastSeenAt)}</td>
|
||||||
|
<td>
|
||||||
|
{!session.current && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="linklike"
|
||||||
|
onClick={() => revoke.mutate(session.id)}
|
||||||
|
>
|
||||||
|
{t('settings:sessions.revoke')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{others.length > 0 ? (
|
{others.length > 0 ? (
|
||||||
<button type="button" className="button" onClick={() => revokeOthers.mutate()}>
|
<button type="button" className="button" onClick={() => revokeOthers.mutate()}>
|
||||||
{t('settings:sessions.revokeAll')}
|
{t('settings:sessions.revokeAll')}
|
||||||
|
|||||||
@ -966,6 +966,14 @@ button {
|
|||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wrapper for tables that can outgrow a narrow viewport (issue #301). The
|
||||||
|
table keeps its own scrollbar; `tabindex` makes that scroll area reachable
|
||||||
|
by keyboard, which a bare overflow container is not. */
|
||||||
|
.table-scroll {
|
||||||
|
overflow-x: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@ -3761,6 +3769,10 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
margin: var(--space-2) 0;
|
margin: var(--space-2) 0;
|
||||||
|
/* Radio + label + accent swatches must be allowed to break onto a second
|
||||||
|
line at 320px (issue #301, WCAG 1.4.10) — the swatches have a fixed size
|
||||||
|
and cannot shrink, so without this the row sets a floor for the page. */
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Radio groups in settings sections (issue #180). */
|
/* Radio groups in settings sections (issue #180). */
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user