ee8797c142
Co-authored-by: Copilot <copilot@github.com>
176 lines
4.8 KiB
Markdown
176 lines
4.8 KiB
Markdown
# Breadcrumb
|
||
|
||
A navigation trail showing the user's location in the app hierarchy. Items are separated by chevron icons. The last item is always rendered as plain text (current page); earlier items are links.
|
||
|
||
---
|
||
|
||
## HTML structure
|
||
|
||
```
|
||
nav[aria-label=Breadcrumb]
|
||
ol.flex.flex-wrap.items-center.gap-1.5.text-sm.text-muted-foreground
|
||
li.inline-flex.items-center.gap-1.5 ← one per item
|
||
a | span ← a = link, span = non-linked or current
|
||
span[role=presentation, aria-hidden] ← chevron separator (omitted after last item)
|
||
svg (3.5×3.5, chevron-right)
|
||
```
|
||
|
||
---
|
||
|
||
## CSS mechanics
|
||
|
||
| Class | Effect |
|
||
|---|---|
|
||
| `text-muted-foreground` | Dimmed color for all non-current items |
|
||
| `font-normal text-foreground` | Full-opacity color applied to the last (current) item |
|
||
| `hover:text-foreground transition-colors` | Link hover state |
|
||
| `flex-wrap` | Items wrap on narrow screens |
|
||
|
||
---
|
||
|
||
## Constructor signature
|
||
|
||
```csharp
|
||
public Breadcrumb(IEnumerable<(string Label, string Href)> items)
|
||
```
|
||
|
||
| Parameter | Description |
|
||
|---|---|
|
||
| `items` | Ordered list of `(Label, Href)` tuples. The last item is always the current page. |
|
||
|
||
Rules:
|
||
- The **last** item is always non-linked and rendered in full `text-foreground` color, regardless of its `Href` value.
|
||
- Any **non-last** item with an empty `Href` is rendered as a plain `<span>` rather than a link.
|
||
|
||
---
|
||
|
||
## Usage examples
|
||
|
||
### Simple three-level breadcrumb
|
||
|
||
```csharp
|
||
new Breadcrumb(new[]
|
||
{
|
||
("Home", "/"),
|
||
("Settings", "/settings"),
|
||
("Profile", ""), // current page — href is ignored for the last item
|
||
})
|
||
```
|
||
|
||
### Dynamic breadcrumb from a data path
|
||
|
||
```csharp
|
||
// Build items from a category tree
|
||
var crumbs = categoryPath
|
||
.Select((cat, i) => (cat.Name, i < categoryPath.Count - 1 ? cat.Url : ""))
|
||
.ToArray();
|
||
|
||
new Breadcrumb(crumbs)
|
||
```
|
||
|
||
### Embedded in a page
|
||
|
||
```html
|
||
<!-- MyPage.htmx -->
|
||
<div class="mb-6">
|
||
$$Breadcrumb$$
|
||
</div>
|
||
```
|
||
|
||
```csharp
|
||
// MyPage.htmx.cs
|
||
public IHtmxComponent Breadcrumb { get; }
|
||
|
||
public MyPage()
|
||
{
|
||
Breadcrumb = new Breadcrumb(new[]
|
||
{
|
||
("Home", "/"),
|
||
("Reports", "/reports"),
|
||
("Monthly", ""),
|
||
});
|
||
}
|
||
|
||
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
||
=> Breadcrumb.Render(ctx.Next());
|
||
```
|
||
|
||
---
|
||
|
||
## Tips and tricks
|
||
|
||
- Always make the last item the current page — its href is ignored anyway, and it gets the visual "active" treatment automatically.
|
||
- If you have a non-navigable segment (e.g. a category separator with no URL), pass an empty `Href` for that item and it will render as a plain span.
|
||
- For very deep hierarchies, consider truncating the middle items and replacing them with a `…` span — build the items list conditionally before passing to the constructor.
|
||
- The chevron separator is `aria-hidden` so screen readers announce only the labels in sequence.
|
||
|
||
---
|
||
|
||
## Complete page example
|
||
|
||
**`Templates/ArticlePage.htmx`**
|
||
```html
|
||
<div class="max-w-3xl mx-auto py-10">
|
||
<div class="mb-6">$$Breadcrumb$$</div>
|
||
<h1 class="text-3xl font-bold mb-4">$$ArticleTitle$$</h1>
|
||
<div class="prose">$$ArticleBody$$</div>
|
||
</div>
|
||
```
|
||
|
||
**`Templates/ArticlePage.htmx.cs`**
|
||
```csharp
|
||
namespace Htmx.ApiDemo.Templates;
|
||
|
||
public sealed class ArticlePage : ArticlePageBase
|
||
{
|
||
private readonly IHtmxComponent _breadcrumb;
|
||
private readonly byte[] _title;
|
||
private readonly byte[] _body;
|
||
|
||
public ArticlePage(string category, string categorySlug, Article article)
|
||
{
|
||
_breadcrumb = new Components.Breadcrumb(new[]
|
||
{
|
||
("Home", "/"),
|
||
("Blog", "/blog"),
|
||
(category, $"/blog/{categorySlug}"),
|
||
(article.Title, ""), // current page
|
||
});
|
||
|
||
_title = System.Net.WebUtility.HtmlEncode(article.Title).ToUtf8Bytes();
|
||
_body = article.HtmlContent.ToUtf8Bytes();
|
||
}
|
||
|
||
protected override void RenderBreadcrumb(HtmxRenderContext ctx)
|
||
=> _breadcrumb.Render(ctx.Next());
|
||
protected override void RenderArticleTitle(HtmxRenderContext ctx)
|
||
=> ctx.Writer.WriteUtf8(_title);
|
||
protected override void RenderArticleBody(HtmxRenderContext ctx)
|
||
=> ctx.Writer.WriteUtf8(_body);
|
||
}
|
||
```
|
||
|
||
**GET handler**
|
||
```csharp
|
||
[Handler]
|
||
[MapGet("/blog/{category}/{slug}")]
|
||
public static partial class GetArticleHandler
|
||
{
|
||
public record Query(
|
||
[property: FromRoute] string Category,
|
||
[property: FromRoute] string Slug);
|
||
|
||
private static async Task<IResult> HandleAsync(
|
||
Query q,
|
||
HttpContext ctx,
|
||
ArticleService articles,
|
||
CancellationToken ct)
|
||
{
|
||
var article = await articles.GetBySlugAsync(q.Slug, ct);
|
||
if (article is null) return Results.NotFound();
|
||
var page = new ArticlePage(q.Category, q.Category.ToLower(), article);
|
||
return await ctx.WriteHtmxPage(page, title: article.Title);
|
||
}
|
||
}
|
||
```
|