ee8797c142
Co-authored-by: Copilot <copilot@github.com>
173 lines
5.6 KiB
Markdown
173 lines
5.6 KiB
Markdown
# Pagination
|
||
|
||
A page navigation row with Prev/Next links and numbered page buttons. The current page is highlighted. Links are built from a URL pattern.
|
||
|
||
---
|
||
|
||
## HTML structure
|
||
|
||
```
|
||
nav[aria-label=Pagination].flex.items-center.justify-center.gap-1
|
||
a.pag-btn[href=prevUrl, aria-label=Previous] ← disabled styling when current=1
|
||
svg (chevron-left)
|
||
a.pag-btn[href=url] ← one per page in the visible window
|
||
{pageNumber} ← current page has pag-btn-active class
|
||
span.pag-ellipsis ← rendered when pages are skipped
|
||
a.pag-btn[href=nextUrl, aria-label=Next] ← disabled styling when current=total
|
||
svg (chevron-right)
|
||
```
|
||
|
||
---
|
||
|
||
## CSS mechanics
|
||
|
||
| Class | Effect |
|
||
|---|---|
|
||
| `pag-btn` | `inline-flex h-9 w-9 items-center justify-center rounded-md border border-input bg-background text-sm hover:bg-accent` |
|
||
| `pag-btn-active` | `bg-primary text-primary-foreground border-primary hover:bg-primary/90` |
|
||
| `pag-ellipsis` | `inline-flex h-9 w-9 items-center justify-center text-sm text-muted-foreground` |
|
||
| `pointer-events-none opacity-50` | Applied to Prev when `current == 1`, to Next when `current == total` |
|
||
|
||
The visible page window is limited to 7 buttons maximum. For large page counts the component collapses interior pages into ellipsis spans, keeping first page, last page, and the pages immediately around `current` always visible.
|
||
|
||
---
|
||
|
||
## Constructor signature
|
||
|
||
```csharp
|
||
public Pagination(
|
||
int current,
|
||
int total,
|
||
string urlPattern)
|
||
```
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `current` | 1-based current page number |
|
||
| `total` | Total number of pages |
|
||
| `urlPattern` | URL template with `{0}` replaced by the page number, e.g. `"/items?page={0}"` |
|
||
|
||
---
|
||
|
||
## Usage examples
|
||
|
||
### Basic pagination
|
||
|
||
```csharp
|
||
new Pagination(current: 3, total: 10, urlPattern: "/blog?page={0}")
|
||
```
|
||
|
||
### Preserving query parameters
|
||
|
||
```csharp
|
||
// Build the URL pattern from the current request
|
||
var query = HttpUtility.ParseQueryString(Request.QueryString.ToString());
|
||
query["page"] = "{0}";
|
||
var pattern = "/search?" + query.ToString();
|
||
|
||
new Pagination(current: page, total: totalPages, urlPattern: pattern)
|
||
```
|
||
|
||
### HTMX-powered pagination (swap content without full navigation)
|
||
|
||
The links are standard `<a>` tags. To intercept them with HTMX, use `hx-boost` on the container or wrap in a boosted `<div>`:
|
||
|
||
```html
|
||
<div hx-boost="true" hx-target="#item-list" hx-push-url="true">
|
||
$$Pagination$$
|
||
</div>
|
||
```
|
||
|
||
### Single page (hides naturally)
|
||
|
||
```csharp
|
||
// When total == 1, Prev and Next are both disabled and only "1" is rendered.
|
||
new Pagination(current: 1, total: 1, urlPattern: "/items?page={0}")
|
||
```
|
||
|
||
---
|
||
|
||
## Tips and tricks
|
||
|
||
- The `urlPattern` uses `string.Format`-style `{0}` — do not use `{page}` or other named placeholders.
|
||
- Page numbers are 1-based throughout — the first page is page `1`.
|
||
- When `total` is 0 or negative the component renders nothing — guard `total > 1` in the page if you want to hide it entirely when there is only one page.
|
||
- To preserve sort order or filters across pages, include those values in the `urlPattern` query string.
|
||
- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
|
||
- For very small page counts (2–3 pages), all page buttons are shown with no ellipsis.
|
||
|
||
---
|
||
|
||
## Complete page example
|
||
|
||
**`Templates/BlogPage.htmx`**
|
||
```html
|
||
<div class="max-w-3xl mx-auto py-10">
|
||
<h1 class="text-2xl font-bold mb-6">Blog</h1>
|
||
<div class="space-y-6 mb-10">
|
||
$$PostList$$
|
||
</div>
|
||
$$Pagination$$
|
||
</div>
|
||
```
|
||
|
||
**`Templates/BlogPage.htmx.cs`**
|
||
```csharp
|
||
namespace Htmx.ApiDemo.Templates;
|
||
|
||
public sealed class BlogPage : BlogPageBase
|
||
{
|
||
private readonly byte[] _postList;
|
||
private readonly IHtmxComponent _pagination;
|
||
|
||
public BlogPage(IEnumerable<BlogPost> posts, int currentPage, int totalPages)
|
||
{
|
||
var sb = new System.Text.StringBuilder();
|
||
foreach (var post in posts)
|
||
{
|
||
sb.Append($"""
|
||
<article class="border-b pb-6">
|
||
<h2 class="text-lg font-semibold mb-1">
|
||
<a href="/blog/{System.Net.WebUtility.HtmlEncode(post.Slug)}"
|
||
class="hover:underline">
|
||
{System.Net.WebUtility.HtmlEncode(post.Title)}
|
||
</a>
|
||
</h2>
|
||
<p class="text-sm text-muted-foreground mb-2">{post.PublishedAt:MMMM d, yyyy}</p>
|
||
<p class="text-sm">{System.Net.WebUtility.HtmlEncode(post.Summary)}</p>
|
||
</article>
|
||
""");
|
||
}
|
||
_postList = sb.ToString().ToUtf8Bytes();
|
||
_pagination = new Components.Pagination(
|
||
current: currentPage,
|
||
total: totalPages,
|
||
urlPattern: "/blog?page={0}");
|
||
}
|
||
|
||
protected override void RenderPostList(HtmxRenderContext ctx) => ctx.Writer.WriteUtf8(_postList);
|
||
protected override void RenderPagination(HtmxRenderContext ctx) => _pagination.Render(ctx.Next());
|
||
}
|
||
```
|
||
|
||
**GET handler**
|
||
```csharp
|
||
[Handler]
|
||
[MapGet("/blog")]
|
||
public static partial class GetBlogHandler
|
||
{
|
||
public record Query([property: FromQuery] int Page = 1);
|
||
|
||
private static async Task<IResult> HandleAsync(
|
||
Query q, HttpContext ctx, BlogService blog, CancellationToken ct)
|
||
{
|
||
const int pageSize = 10;
|
||
var (posts, total) = await blog.GetPageAsync(q.Page, pageSize, ct);
|
||
int totalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||
|
||
return await ctx.WriteHtmxPage(
|
||
new BlogPage(posts, q.Page, totalPages), title: "Blog");
|
||
}
|
||
}
|
||
```
|