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

@@ -23,11 +23,6 @@
color: var(--color-text);
}
.post-card:focus-visible {
outline: 3px solid var(--color-focus);
outline-offset: 2px;
}
/* Icon and date on one line. The icon belongs to Icon's own scope, so it is
reached through ::deep. */
.post-date {
@@ -8,16 +8,39 @@
else if (_post is not null)
{
<PageTitle>@_post.Title</PageTitle>
<nav>
<a href="/posts">&larr; Alle Beiträge</a>
</nav>
<article>
<div class="post-content">
<PostDate Value="_post.Date" />
<h2>@_post.Title</h2>
<div class="markdown-body">@Body</div>
</div>
@* A plain link, not a <nav>: one link is no navigation block, and an unlabelled
second <nav> next to the main menu would only add noise for screen readers. *@
<a class="btn btn-quiet" href="/posts"><Icon Name="arrow-left" Size="18" />Alle Beiträge</a>
<article class="post">
<header class="post-header">
<p class="post-meta"><Icon Name="calendar" Size="16" /><PostDate Value="_post.Date" /></p>
<h1>@_post.Title</h1>
</header>
<div class="markdown-body post-body">@Body</div>
</article>
@if (_neighbors.Previous is not null || _neighbors.Next is not null)
{
@* Older post on the left, newer on the right, like a timeline. A missing side
leaves its cell empty, so the remaining link keeps its place. *@
<nav class="post-pager" aria-label="Weitere Beiträge">
@if (_neighbors.Previous is { } previous)
{
<a class="post-pager-link post-pager-previous" href="/posts/@previous.Slug" rel="prev">
<span class="post-pager-label"><Icon Name="arrow-left" Size="16" />Vorheriger Beitrag</span>
<span class="post-pager-title">@previous.Title</span>
</a>
}
@if (_neighbors.Next is { } next)
{
<a class="post-pager-link post-pager-next" href="/posts/@next.Slug" rel="next">
<span class="post-pager-label">Nächster Beitrag<Icon Name="arrow-right" Size="16" /></span>
<span class="post-pager-title">@next.Title</span>
</a>
}
</nav>
}
}
@* No post and not unavailable: the slug was unknown; NavigationManager.NotFound()
already ran in OnParametersSetAsync and renders the not-found page. *@
@@ -32,6 +32,10 @@ public partial class PostDetail
private Post? _post;
// The older and newer post for the "Vorheriger / Nächster Beitrag" links at the
// end; both empty until loaded or when the post stands alone.
private PostNeighbors _neighbors = new(null, null);
// Set when the content source could not be reached or read. The markup then
// shows a "temporarily unavailable" note instead of a not-found page, matching
// the 503 this sets on the response.
@@ -48,6 +52,7 @@ public partial class PostDetail
var posts = await PocketBase.GetPostsAsync(HttpContext?.RequestAborted ?? CancellationToken.None);
_post = posts.FirstOrDefault(
post => string.Equals(post.Slug, Slug, StringComparison.OrdinalIgnoreCase));
_neighbors = PostNeighbors.Of(posts, Slug);
}
catch (PocketBaseUnavailableException exception)
{
@@ -1,15 +1,108 @@
/* Push the whole article body down from the "all posts" link above it. Wraps the
date, heading and body, so scoped CSS applies (the date sits in the PostDate
child component and would not be reachable on its own). The date's own look
comes from PostDate.razor.css. */
.post-content {
margin-top: 1.5rem;
/* The article: a reading column of about 68 characters with generous leading, so a
long post reads like a page, not like a form. Everything inside follows this
width -- the global 42rem cap on paragraphs, lists and boxes is lifted here, or
the text would stop short of the column it sits in. */
.post {
max-width: 68ch;
margin-top: var(--space-4);
line-height: 1.75;
}
/* The post title is the first h2 in the content, sitting right under its date, so
drop the large default heading gap above it (h1..h4 carry --space-5 top margin
globally); date and title belong together. Scoped to :first-of-type so h2
subheadings inside the Markdown body keep their normal spacing. */
.post-content h2:first-of-type {
margin-top: var(--space-1);
.post ::deep :is(p, ul, ol, blockquote, .aufruf, .hinweis) {
max-width: none;
}
/* Date above the title as a quiet meta line, with the same calendar icon as on the
post cards. The title follows directly, so the gap between them stays small
(h1 carries no top margin globally). */
.post-meta {
display: flex;
align-items: center;
gap: var(--space-1);
margin: 0 0 var(--space-2);
color: var(--color-text-soft);
font-size: 0.95rem;
font-weight: 600;
}
.post-meta ::deep .icon {
color: var(--color-accent-dark);
}
/* The first paragraph as the lead: larger and in the soft tone, so it reads as an
introduction. Pure CSS on the editor's Markdown -- if a post starts with a
heading or a list instead, there simply is no lead. Same text colour token as
the rest, so it keeps AA contrast in both schemes. */
.post-body ::deep > p:first-child {
color: var(--color-text-soft);
font-size: 1.2rem;
line-height: 1.65;
}
/* Older/newer post at the end: two cards side by side, older on the left, newer on
the right. The grid keeps each in its column even if the other is missing. Hover
= border and shadow, nothing moves. */
.post-pager {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
max-width: 68ch;
margin-top: var(--space-6);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border);
}
.post-pager-link {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
text-decoration: none;
transition: box-shadow var(--transition), border-color var(--transition);
}
.post-pager-link:hover {
border-color: var(--color-brand);
box-shadow: var(--shadow-hover);
color: var(--color-text);
}
.post-pager-next {
grid-column: 2;
text-align: right;
}
.post-pager-label {
display: inline-flex;
align-items: center;
gap: var(--space-1);
color: var(--color-text-soft);
font-size: 0.85rem;
font-weight: 700;
}
.post-pager-next .post-pager-label {
align-self: flex-end;
}
.post-pager-title {
color: var(--color-brand);
font-weight: 700;
line-height: 1.3;
}
/* On a phone the two cards stack: a half-width card would squeeze a long title
into a narrow column. Older first, as in the wide layout. */
@media (max-width: 40rem) {
.post-pager {
grid-template-columns: 1fr;
}
.post-pager-next {
grid-column: 1;
}
}
@@ -0,0 +1,65 @@
using Elternbeirat.Contracts;
namespace Elternbeirat.Web.Features.Posts;
/// <summary>
/// The posts next to a given post in time: the one published before it and the
/// one published after it.
/// </summary>
/// <param name="Previous">
/// The older post, or <see langword="null"/> if the post is the oldest.
/// </param>
/// <param name="Next">
/// The newer post, or <see langword="null"/> if the post is the newest.
/// </param>
/// <remarks>
/// "Previous" and "next" follow time, as a reader moving through the news would:
/// "Vorheriger Beitrag" is the older one, "Nächster Beitrag" the newer one. The
/// list order of the posts page (newest first) is the other way round, which is
/// why the direction is pinned down here instead of being left to the markup.
/// </remarks>
/// <seealso cref="PostDetail"/>
public sealed record PostNeighbors(Post? Previous, Post? Next)
{
/// <summary>
/// Finds the neighbours of the post with the given slug.
/// </summary>
/// <param name="posts">
/// All public posts, newest first, as <c>PocketBaseClient.GetPostsAsync</c>
/// returns them.
/// </param>
/// <param name="slug">The slug of the post being shown.</param>
/// <returns>
/// The older and the newer post. Both are <see langword="null"/> if the slug is
/// not in the list, or if it is the only post.
/// </returns>
/// <remarks>
/// The slug is compared case-insensitively, like the route lookup in
/// <see cref="PostDetail"/>, so a page reached as <c>/posts/New-Board</c> still
/// gets its neighbours.
/// </remarks>
/// <example>
/// <code>
/// // posts = [March, February, January] (newest first)
/// var neighbors = PostNeighbors.Of(posts, "february");
/// // neighbors.Previous is January, neighbors.Next is March
/// </code>
/// </example>
public static PostNeighbors Of(IReadOnlyList<Post> posts, string slug) =>
IndexOf(posts, slug) is var index and >= 0
? new PostNeighbors(
Previous: index + 1 < posts.Count ? posts[index + 1] : null,
Next: index > 0 ? posts[index - 1] : null)
: new PostNeighbors(null, null);
/// <summary>
/// The position of the post with <paramref name="slug"/>, or <c>-1</c>.
/// </summary>
private static int IndexOf(IReadOnlyList<Post> posts, string slug) =>
posts
.Select((post, index) => (post, index))
.FirstOrDefault(
entry => string.Equals(entry.post.Slug, slug, StringComparison.OrdinalIgnoreCase),
(null!, -1))
.index;
}