Rework post page (lead, pager), add skip link, unified focus ring, reduced motion and keyboard-accessible menu
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user