Navigation components
Parameters, slots, and NavigationInfo bindings for the four Pennington.UI navigation components — Table
The four navigation-oriented Razor components in Pennington.UI. TableOfContentsNavigation and OutlineNavigation render, respectively, the sidebar page tree and the floating in-page heading outline, and live in namespace Pennington.UI.Components.Navigation. Breadcrumb and Pagination render the article-header trail and prev/numbered/next paging controls, and live in the base namespace Pennington.UI.Components. All four are consumed by Pennington.DocSite's MainLayout.
TableOfContentsNavigation
Declaration
@inject IServiceProvider Services
@if (TableOfContents != null)
{
<nav>
<ul class="@_list">
@foreach (var tableOfContentEntry in TableOfContents.OrderBy(i => i.Order))
{
@TocEntry(tableOfContentEntry)
}
</ul>
</nav>
}
@code {
/// <summary>Navigation tree to render; when null the component renders nothing, and entries are sorted by <see cref="NavigationTreeItem.Order"/> at each level.</summary>
[Parameter] public ImmutableList<NavigationTreeItem>? TableOfContents { get; set; }
/// <summary>Optional label forwarded from the caller's <c>NavigationInfo.SectionName</c>; not rendered by the default template.</summary>
[Parameter] public string? SectionLabel { get; set; }
/// <summary>Classes Tailwind-merged over the <c>toc.list</c> slot on the outer <c><ul></c> that holds the top-level navigation entries.</summary>
[Parameter] public string? ListClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>toc.section</c> slot on each top-level <c><li></c>.</summary>
[Parameter] public string? SectionClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>toc.section-title</c> slot on a section's label — the plain <c><div></c> for empty-route entries, or the <c><a></c> when a top-level entry has children.</summary>
[Parameter] public string? SectionTitleClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>toc.section-list</c> slot on the nested <c><ul></c> that holds a section's child entries.</summary>
[Parameter] public string? SectionListClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>toc.link</c> slot on each child-level <c><a></c>; the slot also carries the <c>data-current=true</c> state styling.</summary>
[Parameter] public string? LinkClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>toc.top-link</c> slot on a top-level leaf <c><a></c> (an entry with no children); the slot also carries the <c>data-current=true</c> state styling.</summary>
[Parameter] public string? TopLinkClass { get; set; }
// Optional resolve: bare hosts that never call AddPenningtonStyles still render with the
// built-in defaults. Blazor's [Inject] has no optional mode, hence GetService.
private StyleRegistry? _styles;
private string _list = "";
private string _section = "";
private string _sectionTitle = "";
private string _sectionList = "";
private string _link = "";
private string _topLink = "";
protected override void OnInitialized() => _styles = Services.GetService<StyleRegistry>();
protected override void OnParametersSet()
{
_list = Resolve(ListClass, StyleKeys.TocList);
_section = Resolve(SectionClass, StyleKeys.TocSection);
_sectionTitle = Resolve(SectionTitleClass, StyleKeys.TocSectionTitle);
_sectionList = Resolve(SectionListClass, StyleKeys.TocSectionList);
_link = Resolve(LinkClass, StyleKeys.TocLink);
_topLink = Resolve(TopLinkClass, StyleKeys.TocTopLink);
}
// Parameters Tailwind-merge over the resolved slot; without a registry (bare host) the
// parameter is appended, accepting that conflicting base utilities are not removed.
private string Resolve(string? parameter, string key)
=> _styles?.Merge(key, parameter)
?? (string.IsNullOrEmpty(parameter) ? UiStyleDefaults.Get(key) : $"{UiStyleDefaults.Get(key)} {parameter}".Trim());
private RenderFragment TocEntry(NavigationTreeItem tocEntry) =>
@<li class="@_section">
@if (tocEntry.Route.CanonicalPath.Value == "")
{
<div class="@_sectionTitle">@tocEntry.Title</div>
}
else
{
<a data-current="@tocEntry.IsSelected.ToString().ToLowerInvariant()" href="@tocEntry.Route.CanonicalPath.Value" class="@(tocEntry.Children.Count == 0 ? _topLink : _sectionTitle)">@tocEntry.Title</a>
}
@if (tocEntry.Children.Count > 0)
{
<ul class="@_sectionList">
@foreach (var childEntry in tocEntry.Children.OrderBy(i => i.Order).Where(i => i.Route.CanonicalPath.Value != ""))
{
<li>
<a data-current="@childEntry.IsSelected.ToString().ToLowerInvariant()" href="@childEntry.Route.CanonicalPath.Value" class="@_link">@childEntry.Title</a>
</li>
}
</ul>
}
</li>;
}
Renders an ordered <nav><ul> of NavigationTreeItem entries, recursing one level into each entry's Children collection and sorting by NavigationTreeItem.Order at each level. Root entries with an empty Route.CanonicalPath render as plain section headers; entries with a path render as anchor links carrying data-current="true" when IsSelected is set.
Parameters
Every *Class parameter defaults to null and resolves through the style-registry slot listed in its Default column; an explicitly passed value is Tailwind-merged over the slot for that instance. Run dotnet run -- diag styles for effective slot values, and see Override component styles for overriding slots app-wide.
| Name | Type | Default | Description |
|---|---|---|---|
TableOfContents |
ImmutableList<NavigationTreeItem>? |
null |
Navigation tree to render; when null the component renders nothing. |
SectionLabel |
string? |
null |
Optional label forwarded from the caller's NavigationInfo.SectionName; not rendered by the default template. |
ListClass |
string? |
toc.list slot |
The outer <ul> that holds the top-level navigation entries. |
SectionClass |
string? |
toc.section slot |
Each top-level <li>. |
SectionTitleClass |
string? |
toc.section-title slot |
A section's label — the plain <div> for empty-route entries, or the <a> when a top-level entry has children. |
SectionListClass |
string? |
toc.section-list slot |
The nested <ul> that holds a section's child entries. |
LinkClass |
string? |
toc.link slot |
Each child-level <a>, including its data-current=true state styling. |
TopLinkClass |
string? |
toc.top-link slot |
A top-level leaf <a> (an entry with no children), including its data-current=true state styling. |
Binding
TableOfContents accepts an ImmutableList<NavigationTreeItem> produced by await NavigationBuilder.BuildTreeAsync(items, currentPath, locale). It does not accept a NavigationInfo. SectionLabel is typically passed from NavigationInfo.SectionName. No RenderFragment slots.
Example
@{
var tree = await NavigationBuilder.BuildTreeAsync(items, currentPath, locale);
}
<TableOfContentsNavigation TableOfContents="tree" SectionLabel="@navigation.SectionName" />
OutlineNavigation
Declaration
@inject IServiceProvider Services
@if (!string.IsNullOrWhiteSpace(Title))
{
<div class="@_title">@Title</div>
}
<div data-role="page-outline" data-content-selector="@ContentSelector" class="relative @_container">
<div data-role="page-outline-highlighter" class="absolute opacity-0 @_marker"></div>
<div>
<ul class="@_list"
data-outline-link-class="@_link"
data-outline-nested-link-class="@_nestedLink">
@* Outline links will be dynamically generated by JavaScript *@
</ul>
</div>
</div>
@code {
/// <summary>CSS selector the client-side outline script queries to discover heading elements; must be non-empty for the outline to populate.</summary>
[Parameter, EditorRequired] public string ContentSelector { get; set; } = "";
/// <summary>Optional eyebrow rendered above the outline; pass an empty string to suppress.</summary>
[Parameter] public string Title { get; set; } = "On this page";
/// <summary>Classes Tailwind-merged over the <c>outline.title</c> slot on the eyebrow above the outline list.</summary>
[Parameter] public string? TitleClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>outline.container</c> slot on the outer <c>data-role="page-outline"</c> container; <c>relative</c> stays hardcoded for marker positioning.</summary>
[Parameter] public string? ContainerClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>outline.marker</c> slot on the moving highlight bar; <c>absolute</c> and <c>opacity-0</c> stay hardcoded — the client script positions the bar and toggles its opacity.</summary>
[Parameter] public string? MarkerClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>outline.list</c> slot on the outline <c><ul></c>.</summary>
[Parameter] public string? ListClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>outline.link</c> slot, emitted as <c>data-outline-link-class</c> and applied by the client-side script to each generated <c><a></c>; the slot also carries the <c>data-selected=true</c> state styling.</summary>
[Parameter] public string? LinkClass { get; set; }
/// <summary>Classes Tailwind-merged over the <c>outline.nested-link</c> slot, emitted as <c>data-outline-nested-link-class</c> and appended by the client-side script to nested (H3-level) outline links.</summary>
[Parameter] public string? NestedLinkClass { get; set; }
// Optional resolve: bare hosts that never call AddPenningtonStyles still render with the
// built-in defaults. Blazor's [Inject] has no optional mode, hence GetService.
private StyleRegistry? _styles;
private string _title = "";
private string _container = "";
private string _marker = "";
private string _list = "";
private string _link = "";
private string _nestedLink = "";
protected override void OnInitialized() => _styles = Services.GetService<StyleRegistry>();
protected override void OnParametersSet()
{
_title = Resolve(TitleClass, StyleKeys.OutlineTitle);
_container = Resolve(ContainerClass, StyleKeys.OutlineContainer);
_marker = Resolve(MarkerClass, StyleKeys.OutlineMarker);
_list = Resolve(ListClass, StyleKeys.OutlineList);
_link = Resolve(LinkClass, StyleKeys.OutlineLink);
_nestedLink = Resolve(NestedLinkClass, StyleKeys.OutlineNestedLink);
}
// Parameters Tailwind-merge over the resolved slot; without a registry (bare host) the
// parameter is appended, accepting that conflicting base utilities are not removed.
private string Resolve(string? parameter, string key)
=> _styles?.Merge(key, parameter)
?? (string.IsNullOrEmpty(parameter) ? UiStyleDefaults.Get(key) : $"{UiStyleDefaults.Get(key)} {parameter}".Trim());
}
Emits a data-role="page-outline" container and an empty <ul> whose items are populated client-side by scraping headings from the element matched by ContentSelector. The component performs no server-side heading extraction; the companion script in Pennington.UI/wwwroot/ reads data-content-selector, data-outline-link-class, and data-outline-nested-link-class to build and highlight the outline in the browser.
Parameters
ContentSelector is [EditorRequired]. Every *Class parameter defaults to null and resolves through the style-registry slot listed in its Default column; an explicitly passed value is Tailwind-merged over the slot for that instance. Run dotnet run -- diag styles for effective slot values, and see Override component styles for overriding slots app-wide.
| Name | Type | Default | Description |
|---|---|---|---|
ContentSelector |
string |
"" (required) |
CSS selector the client-side outline script queries to discover heading elements; must be non-empty for the outline to populate. |
Title |
string |
"On this page" |
Eyebrow rendered above the outline list as a <div>; pass an empty string to suppress. |
TitleClass |
string? |
outline.title slot |
The eyebrow above the outline list. |
ContainerClass |
string? |
outline.container slot |
The outer data-role="page-outline" container; relative stays hardcoded for marker positioning. |
MarkerClass |
string? |
outline.marker slot |
The moving highlight bar that tracks the active heading; absolute and opacity-0 stay hardcoded — the script positions the bar and toggles its opacity. |
ListClass |
string? |
outline.list slot |
The outline <ul>. |
LinkClass |
string? |
outline.link slot |
Emitted as data-outline-link-class and applied by the client-side script to each generated <a>, including its data-selected=true state styling. |
NestedLinkClass |
string? |
outline.nested-link slot |
Emitted as data-outline-nested-link-class and appended by the client-side script to nested (H3-level) outline links. |
Binding
The component performs no server-side heading extraction. The outline list is populated at runtime by the companion client script in Pennington.UI/wwwroot/, which queries the element matched by ContentSelector and reads data-content-selector, data-outline-link-class, and data-outline-nested-link-class from the container. NavigationInfo is not consulted. No RenderFragment slots.
Example
<OutlineNavigation ContentSelector="#main-content" Title="On this page" />
Breadcrumb
Declaration
@* Visible breadcrumb trail intended to live inside an article header.
Pairs with the ImmutableList<BreadcrumbItem> exposed by
Pennington.Navigation.NavigationInfo (built by NavigationBuilder).
Use TrailingContent for an "Edit on GitHub" link or other right-aligned
chrome on the same line. *@
@using System.Collections.Immutable
@using Pennington.Navigation
@if (Items.Count > 0)
{
<nav class="flex items-center gap-2 font-display text-[12.5px] font-medium text-base-500 dark:text-base-400 min-w-0 flex-wrap" aria-label="Breadcrumb">
@for (var i = 0; i < Items.Count; i++)
{
var item = Items[i];
var isLast = i == Items.Count - 1;
var url = item.Route?.CanonicalPath.Value;
var hasUrl = !string.IsNullOrEmpty(url);
if (hasUrl && !isLast)
{
<a href="@url" class="hover:text-base-900 dark:hover:text-base-50 transition-colors">@item.Title</a>
}
else if (isLast)
{
<span class="text-base-700 dark:text-base-200" aria-current="page">@item.Title</span>
}
else
{
<span>@item.Title</span>
}
if (!isLast)
{
<span class="text-base-300 dark:text-base-700" aria-hidden="true">/</span>
}
}
@if (TrailingContent is not null)
{
<span class="ml-auto flex items-center gap-3">
@TrailingContent
</span>
}
</nav>
}
@code {
/// <summary>The breadcrumb trail to render. Empty list renders nothing.</summary>
[Parameter] public ImmutableList<BreadcrumbItem> Items { get; set; } = [];
/// <summary>
/// Optional content rendered on the trailing edge of the breadcrumb row
/// (e.g. "Edit on GitHub" link). Pushed right via <c>ml-auto</c>.
/// </summary>
[Parameter] public RenderFragment? TrailingContent { get; set; }
}
Renders a visible breadcrumb trail inside an article header from the ImmutableList<BreadcrumbItem> NavigationBuilder exposes via NavigationInfo. Each item links to its route except the last (which renders as a current-page <span aria-current="page">); the trail renders nothing when the list is empty. TrailingContent supplies optional right-aligned chrome on the same row (an "Edit on GitHub" link, repository metadata).
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
Items |
ImmutableList<BreadcrumbItem> |
[] |
The breadcrumb trail to render. Empty list renders nothing. |
TrailingContent |
RenderFragment? |
null |
Optional content rendered on the trailing edge of the breadcrumb row; pushed right via ml-auto. |
Binding
Items accepts the ImmutableList<BreadcrumbItem> exposed as NavigationInfo.Breadcrumbs. The last item renders as the current page; every prior item with a Route renders as a link. TrailingContent is a RenderFragment slot for right-aligned chrome on the same row.
Example
<Breadcrumb Items="navigation.Breadcrumbs">
<TrailingContent>
<a href="@editUrl">Edit on GitHub</a>
</TrailingContent>
</Breadcrumb>
Pagination
Declaration
@* Prev / numbered / next pagination controls. Caller supplies the page-N URL via UrlFor —
the component is URL-shape agnostic so it works for /archive/page/N/, /tags/{tag}/page/N/,
/docs/page-N/, or anything else. Renders nothing when TotalPages <= 1. *@
@if (TotalPages > 1)
{
<nav class="mt-12 flex items-center justify-between gap-4 border-t border-base-200 dark:border-base-800 pt-6" aria-label="Pagination">
<div class="flex-1">
@if (CurrentPage > 1)
{
<a href="@UrlFor(CurrentPage - 1)" rel="prev" class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-base-700 dark:text-base-200 hover:bg-base-100 dark:hover:bg-base-800 transition-colors">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" class="h-4 w-4">
<path d="M9.25 5.75 6.75 8l2.5 2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
Previous
</a>
}
</div>
<ol class="hidden sm:flex items-center gap-1">
@foreach (var slot in BuildSlots())
{
@if (slot.IsGap)
{
<li aria-hidden="true" class="px-2 text-sm text-base-400 dark:text-base-500">...</li>
}
else if (slot.Page == CurrentPage)
{
<li>
<span aria-current="page" class="inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-semibold bg-primary-600 text-white dark:bg-primary-500">@slot.Page</span>
</li>
}
else
{
<li>
<a href="@UrlFor(slot.Page)" class="inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium text-base-700 dark:text-base-200 hover:bg-base-100 dark:hover:bg-base-800 transition-colors">@slot.Page</a>
</li>
}
}
</ol>
<p class="sm:hidden text-sm text-base-500 dark:text-base-400" aria-live="polite">Page @CurrentPage of @TotalPages</p>
<div class="flex-1 flex justify-end">
@if (CurrentPage < TotalPages)
{
<a href="@UrlFor(CurrentPage + 1)" rel="next" class="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium text-base-700 dark:text-base-200 hover:bg-base-100 dark:hover:bg-base-800 transition-colors">
Next
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" class="h-4 w-4">
<path d="M6.75 5.75 9.25 8l-2.5 2.25" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</a>
}
</div>
</nav>
}
@code {
/// <summary>1-based page index for the current view. Highlighted in the numbered list.</summary>
[Parameter] public int CurrentPage { get; set; } = 1;
/// <summary>Total number of pages. The component renders nothing when this is 1 or less.</summary>
[Parameter] public int TotalPages { get; set; } = 1;
/// <summary>
/// Returns the URL for a given 1-based page index. The component never calls this with
/// the current page, but callers should be prepared to map page <c>1</c> to the canonical
/// (non-paginated) URL of the listing.
/// </summary>
[Parameter] public Func<int, string> UrlFor { get; set; } = page => $"?page={page}";
/// <summary>
/// Number of numeric page links flanking the current page in the truncated list. The
/// first and last pages are always rendered; gaps between them and the window collapse
/// to "...". Default of 1 yields windows like <c>1 ... 4 5 6 ... 12</c>.
/// </summary>
[Parameter] public int SiblingCount { get; set; } = 1;
private IEnumerable<Slot> BuildSlots()
{
if (TotalPages <= 1)
{
yield break;
}
var lo = Math.Max(2, CurrentPage - SiblingCount);
var hi = Math.Min(TotalPages - 1, CurrentPage + SiblingCount);
yield return new Slot(1, false);
if (lo > 2)
{
yield return new Slot(0, true);
}
for (var p = lo; p <= hi; p++)
{
yield return new Slot(p, false);
}
if (hi < TotalPages - 1)
{
yield return new Slot(0, true);
}
if (TotalPages > 1)
{
yield return new Slot(TotalPages, false);
}
}
private readonly record struct Slot(int Page, bool IsGap);
}
Prev / numbered / next pagination controls. URL-pattern agnostic — the caller supplies a Func<int, string> that maps a 1-based page index to a URL, so the same component drives /archive/page/N/, /tags/{tag}/page/N/, or any other pattern. Renders nothing when TotalPages is 1 or less.
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
CurrentPage |
int |
1 |
1-based page index for the current view; highlighted in the numbered list. |
TotalPages |
int |
1 |
Total number of pages. The component renders nothing when this is 1 or less. |
UrlFor |
Func<int, string> |
page => "?page={page}" |
Returns the URL for a given 1-based page index. Callers should map page 1 to the canonical (non-paginated) URL of the listing. |
SiblingCount |
int |
1 |
Number of numeric page links flanking the current page in the truncated list. The first and last pages are always rendered; gaps collapse to .... Default of 1 yields windows like 1 ... 4 5 6 ... 12. |
Binding
Pagination does not consult NavigationInfo. The caller supplies CurrentPage and TotalPages as plain integers and maps each 1-based page index to a URL through the UrlFor delegate, so the same component drives any paging URL shape. No RenderFragment slots.
Example
@{
string PageUrl(int page) => page == 1 ? "/archive/" : $"/archive/page/{page}/";
}
<Pagination CurrentPage="currentPage" TotalPages="totalPages" UrlFor="PageUrl" />
See also
- How-to: Customize the sidebar
- Related reference: Navigation types
- Related reference: Content components