Rework post page (lead, pager), add skip link, unified focus ring, reduced motion and keyboard-accessible menu

This commit is contained in:
tleininger committed 2026-10-01 16:48:52 +02:00
1 parent ae362639e3
commit ba65b18d29
14 files changed
+544 -69

No files matched your search

@@ -6,6 +6,15 @@
immediately), it pins to the top and stays there while the page scrolls. The
footer is kept at the bottom on short pages by a min-height on <main>, not by a
flex wrapper -- see MainLayout.razor.css. *@
@* Skip link: the first stop for the Tab key, straight past the menu to the content.
The href carries the current path because of <base href="/"> in App.razor: a
bare "#main" would resolve to "/#main" and lead every subpage back home.
data-enhance-nav="false" leaves the jump to the browser: blazor.web.js would
otherwise take the click, only scroll to <main> and keep the focus on the link,
so the next Tab would land in the header menu again. *@
<a class="skip-link" href="@SkipLinkHref" data-enhance-nav="false">Zum Inhalt springen</a>
<header class="site-header">
<div class="header-inner">
@* Two-line lettering next to the signet: the body's name large, the
@@ -18,16 +27,23 @@
</span>
</a>
@* CSS-only mobile menu: the hidden checkbox holds the open/closed
state, the label is the hamburger button, and :checked reveals the
nav below (see MainLayout.razor.css). No JavaScript, no @@rendermode
-- works under static SSR. *@
<input type="checkbox" id="nav-toggle" class="nav-toggle" />
<label for="nav-toggle" class="nav-toggle-label" aria-label="Menü öffnen und schließen">
@* CSS-only mobile menu: the checkbox holds the open/closed state, the
label is the hamburger button, and :checked reveals the nav below (see
MainLayout.razor.css). No JavaScript, no @@rendermode -- works under
static SSR. The checkbox is only visually hidden on a phone, not
removed, so it is what keyboard and screen-reader users operate: Tab
reaches it, Space toggles it, and its checked state is the menu's
open/closed state, announced as such ("Hauptmenü anzeigen,
Kontrollkästchen, aktiviert"). Its name is the label's hidden text;
aria-controls ties it to the menu it opens. aria-expanded would need
JavaScript to keep it in sync, the native checked state does not. *@
<input type="checkbox" id="nav-toggle" class="nav-toggle visually-hidden" aria-controls="main-nav" />
<label for="nav-toggle" class="nav-toggle-label">
<span class="nav-toggle-bar"></span>
<span class="visually-hidden">Hauptmenü anzeigen</span>
</label>
<nav class="main-nav" aria-label="Hauptmenü">
<nav id="main-nav" class="main-nav" aria-label="Hauptmenü">
@* The home link comes from PocketBase like every other entry (the
"home" page carries location "header"), so it is rendered by the
loop below -- no separate hard-coded home link, which would show
@@ -40,7 +56,9 @@
</div>
</header>
<main class="content">
@* The skip link's target. tabindex="-1" makes it focusable by the link alone
(not by Tab), so after the jump the next Tab continues inside the content. *@
<main id="main" class="content" tabindex="-1">
@Body
</main>
@@ -23,6 +23,12 @@ public partial class MainLayout
private IReadOnlyList<NavLinkItem> _header = [];
private IReadOnlyList<NavLinkItem> _footer = [];
// The skip link's target on this very page: path and query of the request plus
// the fragment. See the comment on the skip link in the markup for why it cannot
// be a bare "#main".
private string SkipLinkHref =>
$"{HttpContext?.Request.PathBase}{HttpContext?.Request.Path}{HttpContext?.Request.QueryString}#main";
// Berlin's year, not the server's (UTC) -- see FooterYear.
private readonly int _year = FooterYear.For(TimeProvider.System.GetUtcNow());
@@ -62,6 +62,12 @@
color: var(--color-on-header);
}
/* On the blue gradient the global brand-blue focus ring would vanish; white
stays visible on both ends of it. */
.brand:focus-visible {
outline-color: var(--color-focus-inverse);
}
/* The signet lives in its own component (Signet.razor), so its <svg> carries that
component's scope attribute, not this one's; ::deep reaches it from here. */
.brand ::deep .signet {
@@ -97,7 +103,9 @@
}
/* The hamburger button and its state checkbox are hidden on wide screens; the
media query below turns them on for narrow ones. */
media query below turns them on for narrow ones. display:none (beating the
global .visually-hidden the checkbox also carries) takes the checkbox out of the
tab order here, where there is no menu to open. */
.nav-toggle,
.nav-toggle-label {
display: none;
@@ -205,6 +213,11 @@
text-decoration: underline;
}
/* Same reason as the brand link in the header: a light ring on the dark band. */
.footer-nav a:focus-visible {
outline-color: var(--color-focus-inverse);
}
/* Copyright: a thin rule separates it from the columns. The rule spans the content
width, not the whole window, so it lines up with the columns above. */
.footer-copyright {
@@ -228,6 +241,19 @@
justify-content: space-between;
}
/* The checkbox comes back as a real, focusable control -- only visually hidden
(global .visually-hidden), so Tab stops on it and Space toggles the menu. It
lives under the label, so the focus ring is drawn there instead. */
.nav-toggle {
display: block;
}
.nav-toggle:focus-visible + .nav-toggle-label {
outline: 3px solid var(--color-focus-inverse);
outline-offset: 2px;
border-radius: var(--radius);
}
/* Show the hamburger button. */
.nav-toggle-label {
display: block;
@@ -271,20 +297,29 @@
transform: translateY(-2px) rotate(-45deg); /* back up by its own height */
}
/* The nav takes its own row and is hidden until the checkbox is checked. */
/* The nav takes its own row and is hidden until the checkbox is checked.
max-height:0 alone would only hide it from the eye: its links would still
be Tab stops and still be read out. visibility:hidden takes them out of
both. Transitioning visibility delays the switch to hidden until the fold
has finished, so closing still animates. Opening uses the transition of the
:checked rule below, which leaves visibility out: the links must be Tab
stops the moment Space is pressed, not 0.2s later. */
.main-nav {
flex-basis: 100%;
flex-direction: column;
gap: 0;
max-height: 0;
overflow: hidden;
transition: max-height 0.2s ease;
visibility: hidden;
transition: max-height 0.2s ease, visibility 0.2s;
}
/* Checked state: reveal the stacked links. The max-height caps the open menu
to the space below the sticky header; a longer list scrolls inside it
instead of running off the screen. */
.nav-toggle:checked ~ .main-nav {
visibility: visible;
transition: max-height 0.2s ease;
max-height: 70vh;
overflow-y: auto;
margin-top: 0.5rem;