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
@@ -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">← 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;
|
||||
}
|
||||
Reference in new issue
Block a user