Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,31 @@ All notable changes to ShellDocs land here. Format follows [Keep a Changelog](ht

## [Unreleased]

## [0.1.7-alpha] — 2026-09-06

Fixes the interactivity gap the `0.1.5-alpha` static-prerender pipeline opened up. Four chrome interactions (sidebar section expand/collapse, package selector dropdown, TableOfContents scroll-spy, PreviewFrame source view) were written as ordinary interactive Razor components with `@onclick` handlers that mutate `[Parameter] bool` state and re-render via `StateHasChanged()`. Prerendered HTML captured only the initial state; on a static host with no Blazor runtime, every one of those interactions was dead on the deployed site.

The `0.1.5-alpha` CHANGELOG glossed this as "SignalR-backed component state doesn't survive the static build — but a docs site doesn't need it." That was wrong. A docs site's primary navigation surface is the sidebar; if you can't expand a section, you can't reach the pages under it.

### Fixed

- **Sidebar section expand/collapse works on static-hosted builds.** `DocsSidebarNode.razor` no longer routes clicks through Blazor's `@onclick="Toggle"` + `_isOpen` state. Instead, `shelldocs.js` attaches a delegated `click` listener on `.sidebar-section-toggle` and flips `[data-open]` on the ancestor `.sidebar-section` and its `.sidebar-section-shell` child. The initial `[data-open]` value (from `OnParametersSet`'s active-path check) still comes from server rendering — so the ancestor of the current page pre-expands correctly on first paint. CSS unchanged; it already selected on `[data-open]` for both the chevron rotate and the grid-rows animation.
- **Package selector dropdown opens/closes on static-hosted builds.** `PackageSelector.razor` always renders the `.pkg-menu` now (previously conditional on `_open`); CSS hides it under `display: none` unless `.pkg[data-open="true"]`. Delegated JS handler on `.pkg-trigger` flips `[data-open]`; outside-click closes any open menu. Options are plain `<a href={RootUrl}>` — native navigation, no Blazor round-trip.
- **TableOfContents scroll-spy attaches on static-hosted builds.** Removed the `OnAfterRenderAsync` → `shelldocsToc.attach` invocation (which only fires with a live Blazor runtime). TOC list now emits `[data-toc-list]` + `[data-toc-ids="id1,id2,..."]`; `shelldocs.js` scans for these on `DOMContentLoaded` and after Blazor `enhancedload` and calls `shelldocsToc.attach` itself. Anchor click uses native `href="#id"` navigation.
- **`PreviewFrame` and `ComponentPreview` source-view expand/collapse work on static-hosted builds.** Removed `@onclick="Expand"` / `Collapse` / `Show` / `Hide` and the `_expanded` / `_showSource` state fields. Buttons carry `data-preview-toggle="expand|collapse"`; `shelldocs.js` toggles the same `.expanded` / `.collapsed` classes the Blazor state used to toggle. The copy button follows the same pattern (`[data-preview-copy]`).

### Notes

Every one of these interactions still works under a live Blazor runtime — the DOM emits the same initial state Blazor's rendering produced; JS mutations happen on top. Delegated document-level click listeners survive Blazor's enhanced-nav DOM swap without re-attaching; only the TOC scroll-spy needs re-init on `enhancedload` because heading IDs change per page.

### Removed

- `DocsSidebarNode.Toggle()` method — click handling is JS-side now.
- `PackageSelector._open` state, `Toggle()`, `Choose()`, `OnBlur()` — same reason; navigation is plain anchor.
- `TableOfContents._handle` / `_sig` / `Scroll()` and the `IJSObjectReference` / `IAsyncDisposable` machinery — scroll-spy attaches from JS, scroll-to uses native anchor.
- `PreviewFrame._expanded` / `_copied` / `_highlighted` / `_codeEl` / `Expand()` / `Collapse()` / `Copy()` — all JS-side.
- `ComponentPreview._showSource` / `_copied` / `_highlighted` / `_sourceEl` / `Show()` / `Hide()` / `Copy()` — same.

## [0.1.6-alpha] — 2026-08-22

Three authoring / SEO features that stack together to make writing per-component docs and shipping a public site substantially less manual.
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

<!-- Package metadata (applies to any project with IsPackable=true) -->
<PropertyGroup>
<Version>0.1.6-alpha</Version>
<Version>0.1.7-alpha</Version>
<Authors>ShellUI</Authors>
<Company>ShellUI</Company>
<Copyright>Copyright © 2026 ShellUI</Copyright>
Expand Down
8 changes: 6 additions & 2 deletions src/ShellDocs.Components/Chrome/DocsSidebarNode.razor
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ else if (Node.Kind == NodeKind.Section)
{
@if (_isToggleable)
{
<button type="button" class="sidebar-section-label sidebar-section-toggle" @onclick="Toggle" aria-expanded="@_isOpen">
@* Click handling lives in shelldocs.js — delegated document
listener flips data-open on the ancestor .sidebar-section.
Works on both live-Blazor and static-prerender deploys. *@
<button type="button" class="sidebar-section-label sidebar-section-toggle" aria-expanded="@_isOpen">
@RenderLabelInner()
<svg class="sidebar-section-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
</button>
Expand Down Expand Up @@ -92,7 +95,8 @@ else
_lastPath = CurrentPath;
}

private void Toggle() => _isOpen = !_isOpen;
// Toggle handled by shelldocs.js — kept as a no-op just in case the
// Blazor lifecycle re-renders and needs the initial state stable.

Comment on lines +98 to 100
private static bool ContainsPath(NavigationNode node, string path)
{
Expand Down
67 changes: 28 additions & 39 deletions src/ShellDocs.Components/Chrome/PackageSelector.razor
Original file line number Diff line number Diff line change
@@ -1,51 +1,50 @@
@inject NavigationManager Nav
@inject ShellDocsOptions Options

@* Dropdown open/close handled by shelldocs.js — delegated click on
.pkg-trigger flips [data-open] on the .pkg root; outside-click closes.
Menu is always rendered; CSS hides it unless [data-open="true"]. *@
@if (Options.Packages.Count > 1)
{
<div class="pkg" @onfocusout="OnBlur">
<button type="button" class="pkg-trigger" @onclick="Toggle" aria-haspopup="listbox" aria-expanded="@_open">
<div class="pkg" data-open="false">
<button type="button" class="pkg-trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="pkg-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="@(Selected.IconPath ?? DefaultIcon)"/>
</svg>
Comment on lines +10 to 14
</span>
<span class="pkg-value">@Selected.Title</span>
<svg class="pkg-chevron @(_open ? "open" : "")" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<svg class="pkg-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>

@if (_open)
{
<div class="pkg-menu" role="listbox">
@foreach (var pkg in Options.Packages)
{
var isSel = pkg.Id == Selected.Id;
<button type="button" class="pkg-option @(isSel ? "selected" : "")" role="option" aria-selected="@isSel" @onclick="() => Choose(pkg)">
<span class="pkg-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="@(pkg.IconPath ?? DefaultIcon)"/>
</svg>
</span>
<span class="pkg-option-body">
<span class="pkg-option-title">@pkg.Title</span>
<span class="pkg-option-desc">@pkg.Description</span>
</span>
@if (isSel)
{
<svg class="pkg-option-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
}
</button>
}
</div>
}
<div class="pkg-menu" role="listbox">
@foreach (var pkg in Options.Packages)
{
var isSel = pkg.Id == Selected.Id;
@* Plain <a> — native navigation, no Blazor round-trip. *@
<a class="pkg-option @(isSel ? "selected" : "")" role="option" aria-selected="@isSel" href="@pkg.RootUrl">
<span class="pkg-option-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
<path d="@(pkg.IconPath ?? DefaultIcon)"/>
</svg>
</span>
<span class="pkg-option-body">
<span class="pkg-option-title">@pkg.Title</span>
<span class="pkg-option-desc">@pkg.Description</span>
</span>
@if (isSel)
{
<svg class="pkg-option-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
}
</a>
}
</div>
</div>
}

@code {
private bool _open;

private const string DefaultIcon = "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z M3.27 6.96 12 12.01l8.73-5.05 M12 22.08V12";

// Longest RootUrl prefix wins — "/docs/components" beats "/docs" for "/docs/components/callout".
Expand All @@ -61,14 +60,4 @@
?? Options.Packages[0];
}
}

private void Toggle() => _open = !_open;

private void Choose(DocsPackage pkg)
{
_open = false;
Nav.NavigateTo(pkg.RootUrl);
}

private void OnBlur(FocusEventArgs _) { /* closes via next click cycle */ }
}
8 changes: 7 additions & 1 deletion src/ShellDocs.Components/Chrome/PackageSelector.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,18 @@
box-shadow: 0 12px 32px -8px rgb(0 0 0 / 0.18), 0 4px 12px -4px rgb(0 0 0 / 0.06);
padding: 0.35rem;
z-index: 30;
display: flex;
/* Always rendered so shelldocs.js can toggle visibility via [data-open]
on the parent .pkg without needing a Blazor re-render. */
display: none;
flex-direction: column;
gap: 0.15rem;
max-height: 22rem;
overflow-y: auto;
}
.pkg[data-open="true"] .pkg-menu { display: flex; }

/* Anchor variant of .pkg-option — same styling as the old <button> variant. */
a.pkg-option { text-decoration: none; }

.pkg-option {
display: flex;
Expand Down
44 changes: 8 additions & 36 deletions src/ShellDocs.Components/Chrome/TableOfContents.razor
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
@inject IJSRuntime JS
@inject DocsPageState PageState
@implements IDisposable
@implements IAsyncDisposable
@using ShellDocs.Core

@* Scroll-spy is set up by shelldocs.js — it scans for [data-toc-list] on
DOMContentLoaded and after Blazor enhanced-nav, calls shelldocsToc.attach
with the id list from data-toc-ids. Anchor scroll uses native href="#id"
navigation; no Blazor click handler needed. Works on static-prerender deploys. *@
@if (_items.Count > 0)
{
<nav class="toc" aria-label="On this page">
<div class="toc-title">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
<span>On this page</span>
</div>
<ul class="toc-list" @ref="_listEl">
<ul class="toc-list" data-toc-list data-toc-ids="@_idsAttr">
<li class="toc-bar" aria-hidden="true"></li>
@foreach (var h in _items)
{
<li class="toc-item" data-level="@h.Level">
<a class="toc-link" href="#@h.Id" data-toc-id="@h.Id"
@onclick="() => Scroll(h.Id)" @onclick:preventDefault="true">@h.Text</a>
<a class="toc-link" href="#@h.Id" data-toc-id="@h.Id">@h.Text</a>
</li>
}
</ul>
Expand All @@ -28,9 +29,7 @@
[Parameter] public IReadOnlyList<Heading>? Headings { get; set; }

private List<Heading> _items = new();
private ElementReference _listEl;
private IJSObjectReference? _handle;
private string _sig = "";
private string _idsAttr = "";

protected override void OnInitialized() => PageState.OnChange += OnPageChanged;

Expand All @@ -42,35 +41,8 @@
{
var source = Headings ?? PageState.Document?.Headings ?? Array.Empty<Heading>();
_items = source.Where(h => h.Level == 2 || h.Level == 3).ToList();
_idsAttr = string.Join(",", _items.Select(h => h.Id));
}

protected override async Task OnAfterRenderAsync(bool _)
{
if (_items.Count == 0) return;
var sig = string.Join(",", _items.Select(h => h.Id));
if (sig == _sig) return;

if (_handle is not null)
{
try { await _handle.InvokeVoidAsync("dispose"); await _handle.DisposeAsync(); } catch { }
_handle = null;
}
try
{
_handle = await JS.InvokeAsync<IJSObjectReference>(
"shelldocsToc.attach", _listEl, _items.Select(h => h.Id).ToArray());
_sig = sig;
}
catch { }
}

private Task Scroll(string id) => JS.InvokeVoidAsync("shelldocsToc.scrollTo", id).AsTask();

public void Dispose() => PageState.OnChange -= OnPageChanged;

public async ValueTask DisposeAsync()
{
if (_handle is null) return;
try { await _handle.InvokeVoidAsync("dispose"); await _handle.DisposeAsync(); } catch { }
}
}
70 changes: 14 additions & 56 deletions src/ShellDocs.Components/Content/ComponentPreview.razor
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
@using System.Text
@using ShellDocs.Markdown
@inject TypeRegistry Registry
@inject IJSRuntime JS

<div class="component-preview @(_showSource ? "expanded" : "collapsed")">
@* Source-view expand/collapse + copy handled by shelldocs.js — same JS
delegation that PreviewFrame uses. Works on static-prerender deploys. *@
<div class="component-preview collapsed">
<div class="component-preview-render">
@if (_target is not null && _targetParams is not null)
{
Expand All @@ -19,29 +20,17 @@
}
</div>
<div class="component-preview-source-wrap">
<pre class="component-preview-source language-razor" @ref="_sourceEl"><code class="language-razor">@_source</code></pre>
@if (!_showSource)
{
<div class="component-preview-fade">
<button type="button" class="component-preview-expand" @onclick="Show">View source</button>
</div>
}
else
{
<div class="component-preview-actions">
<button type="button" class="component-preview-copy @(_copied ? "copied" : "")" @onclick="Copy" aria-label="Copy source">
@if (_copied)
{
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
}
else
{
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
}
</button>
<button type="button" class="component-preview-hide" @onclick="Hide">Hide</button>
</div>
}
<pre class="component-preview-source language-razor"><code class="language-razor">@_source</code></pre>
<div class="component-preview-fade">
<button type="button" class="component-preview-expand" data-preview-toggle="expand">View source</button>
</div>
<div class="component-preview-actions">
<button type="button" class="component-preview-copy" data-preview-copy aria-label="Copy source">
<svg class="icon-copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
<svg class="icon-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button type="button" class="component-preview-hide" data-preview-toggle="collapse">Hide</button>
</div>
Comment on lines +23 to +33
</div>
</div>

Expand All @@ -57,10 +46,6 @@
private Type? _target;
private IDictionary<string, object>? _targetParams;
private string? _source;
private bool _showSource;
private bool _copied;
private bool _highlighted;
private ElementReference _sourceEl;

protected override void OnParametersSet()
{
Expand Down Expand Up @@ -108,31 +93,4 @@
}
return sb.ToString();
}

private void Show() => _showSource = true;
private void Hide() => _showSource = false;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_highlighted && _source is not null)
{
_highlighted = true;
try { await JS.InvokeVoidAsync("shelldocsHighlightElement", _sourceEl); } catch { }
}
}

private async Task Copy()
{
if (_source is null) return;
try
{
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _source);
_copied = true;
StateHasChanged();
await Task.Delay(1400);
_copied = false;
StateHasChanged();
}
catch { }
}
}
Loading
Loading