This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy under /_llms/. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Reference

Navigation components

Parameters, slots, and NavigationInfo bindings for the four Pennington.UI navigation components — TableOfContentsNavigation, OutlineNavigation, Breadcrumb, and Pagination.

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

razor
@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>&lt;ul&gt;</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>&lt;li&gt;</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>&lt;div&gt;</c> for empty-route entries, or the <c>&lt;a&gt;</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>&lt;ul&gt;</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>&lt;a&gt;</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>&lt;a&gt;</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

razor
@{
    var tree = await NavigationBuilder.BuildTreeAsync(items, currentPath, locale);
}
  
<TableOfContentsNavigation TableOfContents="tree" SectionLabel="@navigation.SectionName" />

OutlineNavigation

Declaration

razor
@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>&lt;ul&gt;</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>&lt;a&gt;</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

razor
<OutlineNavigation ContentSelector="#main-content" Title="On this page" />

Declaration

razor
@* 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

razor
<Breadcrumb Items="navigation.Breadcrumbs">
    <TrailingContent>
        <a href="@editUrl">Edit on GitHub</a>
    </TrailingContent>
</Breadcrumb>

Pagination

Declaration

razor
@* 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

razor
@{
    string PageUrl(int page) => page == 1 ? "/archive/" : $"/archive/page/{page}/";
}
  
<Pagination CurrentPage="currentPage" TotalPages="totalPages" UrlFor="PageUrl" />

See also