Compare commits
8 Commits
5164801676
...
ef5f570dbf
| Author | SHA1 | Date | |
|---|---|---|---|
| ef5f570dbf | |||
| 70685968fc | |||
| 0420f97c42 | |||
| 27038a1f27 | |||
| 9c87a14f51 | |||
| 55932b0828 | |||
| 3c1f211f44 | |||
| e48b9dd7df |
@ -620,6 +620,14 @@ jobs:
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
|
||||
|
||||
# Das a11y-Pack kostet seit #301 einen Login mehr (der Reflow-Zaun);
|
||||
# damit reicht das Budget nicht mehr bis in die VS-NfD-Packs → hier
|
||||
# zusätzlich zurücksetzen (siehe Hinweis oben).
|
||||
- name: Reset login rate limit before the VS-NfD packs
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
# VS-NfD-Markierungen im Modus `marked` (issue #244).
|
||||
- name: Run VS-NfD marking pack
|
||||
run: |
|
||||
|
||||
@ -104,3 +104,95 @@ 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 describe = (el: Element): string => {
|
||||
const cls =
|
||||
el.className && typeof el.className === 'string'
|
||||
? `.${el.className.trim().split(/\s+/).join('.')}`
|
||||
: '';
|
||||
return `${el.tagName.toLowerCase()}${cls}`;
|
||||
};
|
||||
|
||||
// Every element whose own content is wider than its box. One of these is
|
||||
// the source; the ones that scroll it away on purpose are marked.
|
||||
const overflowing: string[] = [];
|
||||
for (const el of Array.from(document.querySelectorAll('*'))) {
|
||||
if (el.scrollWidth > el.clientWidth + 1 && el.clientWidth > 0) {
|
||||
const overflowX = getComputedStyle(el).overflowX;
|
||||
overflowing.push(
|
||||
`${describe(el)} client=${el.clientWidth} scroll=${el.scrollWidth} overflow-x=${overflowX}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Content inside a scroll container may exceed the viewport — that is
|
||||
* the remedy. But only when the CONTAINER fits: a scroller that is
|
||||
* itself too wide still pushes the page. */
|
||||
const insideFittingScroller = (el: Element): boolean => {
|
||||
for (let node = el.parentElement; node && node !== doc; node = node.parentElement) {
|
||||
const ox = getComputedStyle(node).overflowX;
|
||||
if (ox === 'auto' || ox === 'scroll' || ox === 'hidden') {
|
||||
return node.getBoundingClientRect().right <= limit + 1;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Widest reach first, so a long tail of clipped children cannot bury the
|
||||
// one box that actually pushes the page.
|
||||
const past = Array.from(document.querySelectorAll('body *'))
|
||||
.map((el) => ({ el, rect: el.getBoundingClientRect() }))
|
||||
.filter(({ rect }) => rect.width > 0 && rect.right > limit + 1)
|
||||
.sort((a, b) => b.rect.right - a.rect.right)
|
||||
.map(
|
||||
({ el, rect }) =>
|
||||
`${describe(el)} right=${Math.round(rect.right)} w=${Math.round(rect.width)}` +
|
||||
`${insideFittingScroller(el) ? ' [in fitting scroller]' : ' <-- pushes page'}`,
|
||||
);
|
||||
|
||||
return {
|
||||
overflowBy: doc.scrollWidth - limit,
|
||||
viewport: `html client=${limit} scroll=${doc.scrollWidth} | body client=${document.body.clientWidth} scroll=${document.body.scrollWidth} rect=${Math.round(document.body.getBoundingClientRect().width)}`,
|
||||
overflowing: overflowing.slice(0, 15),
|
||||
past: past.slice(0, 40),
|
||||
};
|
||||
});
|
||||
const diagnosis = [
|
||||
`${label}: horizontaler Überlauf bei 320 px`,
|
||||
report.viewport,
|
||||
`eigener Inhaltsüberlauf: ${JSON.stringify(report.overflowing, null, 1)}`,
|
||||
`Boxen über dem Rand: ${JSON.stringify(report.past, null, 1)}`,
|
||||
].join('\n');
|
||||
expect({ overflowBy: report.overflowBy }, diagnosis).toEqual({ overflowBy: 0 });
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -172,52 +172,54 @@ function TokenList({ tokens }: { tokens: ApiTokenView[] }): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<FormError error={error} />
|
||||
<table className="table api-tokens__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('fields.name')}</th>
|
||||
<th>{t('fields.scope')}</th>
|
||||
<th>{t('fields.ponds')}</th>
|
||||
<th>{t('list.created')}</th>
|
||||
<th>{t('list.lastUsed')}</th>
|
||||
<th>{t('list.expires')}</th>
|
||||
<th>{t('list.status')}</th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id}>
|
||||
<td>{token.name}</td>
|
||||
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
|
||||
<td>
|
||||
{token.ponds.length === 0
|
||||
? t('list.allPonds')
|
||||
: token.ponds.map((pond) => pond.name).join(', ')}
|
||||
</td>
|
||||
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
|
||||
</td>
|
||||
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
|
||||
<td>{t(`list.${statusOf(token)}`)}</td>
|
||||
<td>
|
||||
{!token.revokedAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="button api-tokens__revoke"
|
||||
onClick={() => void revoke(token.id)}
|
||||
>
|
||||
{t('list.revoke')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<div className="table-scroll" tabIndex={0} role="region" aria-label={t('section.title')}>
|
||||
<table className="table api-tokens__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('fields.name')}</th>
|
||||
<th>{t('fields.scope')}</th>
|
||||
<th>{t('fields.ponds')}</th>
|
||||
<th>{t('list.created')}</th>
|
||||
<th>{t('list.lastUsed')}</th>
|
||||
<th>{t('list.expires')}</th>
|
||||
<th>{t('list.status')}</th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.map((token) => (
|
||||
<tr key={token.id}>
|
||||
<td>{token.name}</td>
|
||||
<td>{token.scope === 'write' ? t('fields.scopeWrite') : t('fields.scopeRead')}</td>
|
||||
<td>
|
||||
{token.ponds.length === 0
|
||||
? t('list.allPonds')
|
||||
: token.ponds.map((pond) => pond.name).join(', ')}
|
||||
</td>
|
||||
<td>{new Date(token.createdAt).toLocaleDateString()}</td>
|
||||
<td>
|
||||
{token.lastUsedAt ? new Date(token.lastUsedAt).toLocaleString() : t('list.never')}
|
||||
</td>
|
||||
<td>{token.expiresAt ? new Date(token.expiresAt).toLocaleDateString() : '—'}</td>
|
||||
<td>{t(`list.${statusOf(token)}`)}</td>
|
||||
<td>
|
||||
{!token.revokedAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="button api-tokens__revoke"
|
||||
onClick={() => void revoke(token.id)}
|
||||
>
|
||||
{t('list.revoke')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -85,32 +85,38 @@ export function FeedTokensSection(): React.JSX.Element {
|
||||
)}
|
||||
{tokens.data && tokens.data.length === 0 && <p>{t('feed.empty')}</p>}
|
||||
{tokens.data && tokens.data.length > 0 && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('fields.name')}</th>
|
||||
<th>{t('list.created')}</th>
|
||||
<th>{t('list.lastUsed')}</th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.data.map((token) => (
|
||||
<tr key={token.id}>
|
||||
<td>{token.name}</td>
|
||||
<td>{formatTime(token.createdAt)}</td>
|
||||
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
|
||||
<td>
|
||||
<button type="button" className="linklike" onClick={() => void remove(token.id)}>
|
||||
{t('feed.delete')}
|
||||
</button>
|
||||
</td>
|
||||
<div className="table-scroll" tabIndex={0} role="region" aria-label={t('feed.title')}>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('fields.name')}</th>
|
||||
<th>{t('list.created')}</th>
|
||||
<th>{t('list.lastUsed')}</th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokens.data.map((token) => (
|
||||
<tr key={token.id}>
|
||||
<td>{token.name}</td>
|
||||
<td>{formatTime(token.createdAt)}</td>
|
||||
<td>{token.lastUsedAt ? formatTime(token.lastUsedAt) : '—'}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="linklike"
|
||||
onClick={() => void remove(token.id)}
|
||||
>
|
||||
{t('feed.delete')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -385,41 +385,56 @@ function SessionsSection(): React.JSX.Element {
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h2>{t('settings:sessions.title')}</h2>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('settings:sessions.device')}</th>
|
||||
<th>{t('settings:sessions.created')}</th>
|
||||
<th>{t('settings:sessions.lastSeen')}</th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(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>
|
||||
{/* A table cannot shrink below its min-content width, so the user-agent
|
||||
column pushed the whole page into horizontal scrolling at 320px
|
||||
(issue #301, WCAG 1.4.10). It scrolls inside its own box instead —
|
||||
the content stays reachable, which `overflow: hidden` would not. */}
|
||||
<div
|
||||
className="table-scroll"
|
||||
// A scroll container is only operable by keyboard once it is
|
||||
// focusable; role+name keep it from being an unlabelled stop.
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={t('settings:sessions.title')}
|
||||
>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('settings:sessions.device')}</th>
|
||||
<th>{t('settings:sessions.created')}</th>
|
||||
<th>{t('settings:sessions.lastSeen')}</th>
|
||||
<th>
|
||||
<span className="visually-hidden">{t('common:tableActions')}</span>
|
||||
</th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(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 ? (
|
||||
<button type="button" className="button" onClick={() => revokeOthers.mutate()}>
|
||||
{t('settings:sessions.revokeAll')}
|
||||
|
||||
@ -966,6 +966,21 @@ button {
|
||||
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%;
|
||||
/* Establishes the containing block for the table's absolutely positioned
|
||||
descendants — the `.visually-hidden` column headings. Without it their
|
||||
containing block is `.app-body`, so they escape this scroller's clipping,
|
||||
keep their static position out at the table's right edge, and push the
|
||||
PAGE (issue #301). Exactly 23px locally, 417px in CI, where different
|
||||
font metrics make the table wider — one cause, two numbers. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@ -3761,6 +3776,10 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
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). */
|
||||
@ -4002,6 +4021,12 @@ ul[data-type='task_list'] li p:last-of-type {
|
||||
position: static;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
/* THE reflow fix (issue #301). As a flex child the nav defaults to
|
||||
`min-width: auto`, i.e. the min-content width of the whole jump
|
||||
strip — so it pushed the column wider than the viewport and its own
|
||||
`overflow-x: auto` never got the chance to scroll anything. Measured
|
||||
at 320px: 417px of page-level overflow, all of it these links. */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-nav ul {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user