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

@@ -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;
}