From efaa862eafade1a96a067552a03dc1b80c10fcd5 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 18 Jul 2026 23:28:03 +0200 Subject: [PATCH 01/13] feat: enhance button accessibility and styling across DocsPage, Home, and Showcase components by adding icons and adjusting padding for improved user experience --- .../ShellDocs.Preview/Components/Pages/DocsPage.razor | 2 +- .../ShellDocs.Preview/Components/Pages/Home.razor | 11 ++++++++--- .../ShellDocs.Preview/Components/Pages/Showcase.razor | 5 ++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor b/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor index 4aebbee..441cf15 100644 --- a/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor +++ b/examples/ShellDocs.Preview/Components/Pages/DocsPage.razor @@ -20,7 +20,7 @@ else

Page not found

The page @Path doesn't exist yet.

-

← Back to introduction

+

Back to introduction

} diff --git a/examples/ShellDocs.Preview/Components/Pages/Home.razor b/examples/ShellDocs.Preview/Components/Pages/Home.razor index bea88b1..e2a02e7 100644 --- a/examples/ShellDocs.Preview/Components/Pages/Home.razor +++ b/examples/ShellDocs.Preview/Components/Pages/Home.razor @@ -196,7 +196,10 @@ Works in Server, WASM, and static output.

Built by the ShellUI team.

Same taste, same craftsmanship, same install-and-go DX. Free forever, MIT-licensed, community-driven.

- Read the docs + + Read the docs + + Contribute on GitHub
@@ -328,16 +331,18 @@ Works in Server, WASM, and static output. .btn { display: inline-flex; align-items: center; - gap: 0.5rem; - padding: 0.65rem 1.25rem; + gap: 0.4rem; + padding: 0.6rem 1.15rem; border-radius: 9999px; border: 1px solid transparent; text-decoration: none; font-weight: 500; font-size: 0.9rem; letter-spacing: -0.005em; + white-space: nowrap; transition: background 150ms, border-color 150ms, color 150ms; } + .btn svg { width: 0.9rem; height: 0.9rem; flex-shrink: 0; } .btn.primary { background: var(--primary); color: var(--primary-foreground); diff --git a/examples/ShellDocs.Preview/Components/Pages/Showcase.razor b/examples/ShellDocs.Preview/Components/Pages/Showcase.razor index 35688c7..eb90ced 100644 --- a/examples/ShellDocs.Preview/Components/Pages/Showcase.razor +++ b/examples/ShellDocs.Preview/Components/Pages/Showcase.razor @@ -45,7 +45,10 @@

Ship your docs on ShellDocs?

Open a PR to get listed here. Include a screenshot, a one-line description, and your live URL.

- Submit on GitHub → + + Submit on GitHub + +
From 17dd16f48fa863e884a8c4cf1ce544ae87999b47 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 18 Jul 2026 23:28:25 +0200 Subject: [PATCH 02/13] feat: add layout variant option for ShellDocs to support floating sidebar navigation --- examples/ShellDocs.Preview/Program.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/ShellDocs.Preview/Program.cs b/examples/ShellDocs.Preview/Program.cs index 1074db8..203ba0c 100644 --- a/examples/ShellDocs.Preview/Program.cs +++ b/examples/ShellDocs.Preview/Program.cs @@ -14,6 +14,8 @@ o.SiteName = "ShellDocs"; o.SiteTagline = "the docs framework for .NET"; o.GitHubRepo = "shellui-dev/shelldocs"; + // Try the new floating-sidebar variant. Flip to TopNav for the classic look. + o.LayoutVariant = DocsLayoutVariant.Sidebar; o.AddNavMenu("Documentation", new NavMenuItem("Getting Started", "/docs/introduction", "Install, configure, and ship your first ShellDocs site.", From 5b8002d34141849ab09f56dc817b199917f14e05 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 19 Jul 2026 23:01:51 +0200 Subject: [PATCH 03/13] feat: integrate search functionality in DocsHeader component by adding OpenSearch method and JS interop --- src/ShellDocs.Components/Chrome/DocsHeader.razor | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ShellDocs.Components/Chrome/DocsHeader.razor b/src/ShellDocs.Components/Chrome/DocsHeader.razor index 6fa6616..e7b4602 100644 --- a/src/ShellDocs.Components/Chrome/DocsHeader.razor +++ b/src/ShellDocs.Components/Chrome/DocsHeader.razor @@ -1,5 +1,6 @@ @inject ShellDocsOptions Options @inject ShellDocs.Components.Chrome.MobileNavState MobileNav +@inject IJSRuntime JS
@@ -62,7 +63,7 @@
- + + + + +
+ +@code { + private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask(); +} diff --git a/src/ShellDocs.Components/Chrome/DocsMobileBar.razor.css b/src/ShellDocs.Components/Chrome/DocsMobileBar.razor.css new file mode 100644 index 0000000..92befee --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsMobileBar.razor.css @@ -0,0 +1,36 @@ +.docs-mobile-bar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + background: color-mix(in oklch, var(--background) 90%, transparent); + backdrop-filter: saturate(180%) blur(12px); + -webkit-backdrop-filter: saturate(180%) blur(12px); + border-bottom: 1px solid var(--border); +} +@media (min-width: 1024px) { + .docs-mobile-bar { display: none; } +} + +::deep .docs-mobile-tool { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + cursor: pointer; + transition: color 150ms, background 150ms; +} +::deep .docs-mobile-tool:hover { color: var(--foreground); background: var(--muted); } +::deep .docs-mobile-tool svg { width: 1.15rem; height: 1.15rem; } + +/* Push ThemeToggle to the right end */ +::deep .theme-toggle { margin-left: auto; } From ea70ef8cc25742de4608626d27af63313c3f8950 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sun, 19 Jul 2026 23:09:11 +0200 Subject: [PATCH 05/13] feat: implement sidebar navigation and search functionality in DocsLayout, enhancing user experience with layout variants and mobile support --- src/ShellDocs.Components/wwwroot/shelldocs.js | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/ShellDocs.Components/wwwroot/shelldocs.js b/src/ShellDocs.Components/wwwroot/shelldocs.js index 652a7a8..bb010d6 100644 --- a/src/ShellDocs.Components/wwwroot/shelldocs.js +++ b/src/ShellDocs.Components/wwwroot/shelldocs.js @@ -1,5 +1,35 @@ window.ShellDocs = window.ShellDocs || {}; +/* Search — global Cmd/Ctrl+K opens the . Bridges to Blazor + via a DotNetObjectReference the dialog registers on first render. */ +window.shelldocsSearch = (function () { + var dotnet = null; + function isModK(e) { + return (e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey); + } + function onKeydown(e) { + if (!isModK(e)) return; + e.preventDefault(); + openInternal(); + } + function openInternal() { + if (dotnet) { dotnet.invokeMethodAsync('OpenFromJs'); } + } + return { + init: function (dotnetRef) { + dotnet = dotnetRef; + document.addEventListener('keydown', onKeydown); + return { + dispose: function () { + document.removeEventListener('keydown', onKeydown); + dotnet = null; + } + }; + }, + open: openInternal + }; +})(); + /* Theme sync — Blazor's enhanced navigation swaps the DOM on route change, which strips the .dark class the head-inline bootstrap set. Re-apply the theme from localStorage after every enhanced-nav commit. */ From 13fafdbc9a2fd4e429ba702cd632e67da53ce8cc Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 20 Jul 2026 21:51:16 +0200 Subject: [PATCH 06/13] feat: add DocsSidebarHeader and SearchDialog components with styling, enhancing sidebar navigation and search capabilities --- .../Chrome/DocsSidebarHeader.razor | 32 +++ .../Chrome/DocsSidebarHeader.razor.css | 93 ++++++++ .../Chrome/PackageSelector.razor | 5 +- .../Chrome/PackageSelector.razor.css | 7 +- .../Chrome/SearchDialog.razor | 208 ++++++++++++++++++ .../Chrome/SearchDialog.razor.css | 167 ++++++++++++++ .../Chrome/SearchState.cs | 14 ++ .../Chrome/SidebarCollapseState.cs | 14 ++ .../Chrome/TableOfContents.razor.css | 12 +- 9 files changed, 540 insertions(+), 12 deletions(-) create mode 100644 src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor create mode 100644 src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor.css create mode 100644 src/ShellDocs.Components/Chrome/SearchDialog.razor create mode 100644 src/ShellDocs.Components/Chrome/SearchDialog.razor.css create mode 100644 src/ShellDocs.Components/Chrome/SearchState.cs create mode 100644 src/ShellDocs.Components/Chrome/SidebarCollapseState.cs diff --git a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor new file mode 100644 index 0000000..190e0f5 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor @@ -0,0 +1,32 @@ +@inject ShellDocsOptions Options +@inject IJSRuntime JS +@inject SidebarCollapseState Collapse + +
+ + + +
+ +@code { + private string ShortcutKey => OperatingSystem.IsMacOS() ? "⌘" : "Ctrl"; + private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask(); +} diff --git a/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor.css b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor.css new file mode 100644 index 0000000..3a74f76 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/DocsSidebarHeader.razor.css @@ -0,0 +1,93 @@ +.docs-sidebar-header { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.85rem 0.75rem 0.5rem 1rem; + flex-shrink: 0; +} + +.docs-sidebar-brand-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.docs-sidebar-brand { + display: inline-flex; + align-items: center; + gap: 0.5rem; + text-decoration: none; + color: var(--foreground); + font-weight: 600; + font-size: 0.95rem; + letter-spacing: -0.01em; + padding: 0.15rem 0.2rem; + border-radius: calc(var(--radius) - 3px); + transition: opacity 150ms; + min-width: 0; + flex: 1; +} +.docs-sidebar-brand:hover { opacity: 0.8; } + +.docs-sidebar-collapse { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.85rem; + height: 1.85rem; + padding: 0; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--muted-foreground); + cursor: pointer; + transition: color 150ms, background 150ms; + flex-shrink: 0; +} +.docs-sidebar-collapse:hover { color: var(--foreground); background: var(--muted); } +.docs-sidebar-collapse svg { width: 1rem; height: 1rem; } +.docs-sidebar-logo-dot { + width: 1.125rem; + height: 1.125rem; + border-radius: 4px; + background: var(--foreground); + flex-shrink: 0; +} + +.docs-sidebar-search { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.5rem 0.4rem 0.6rem; + background: var(--muted); + border: 1px solid transparent; + color: var(--muted-foreground); + border-radius: calc(var(--radius) - 2px); + cursor: pointer; + font-family: inherit; + font-size: 0.8125rem; + text-align: left; + width: 100%; + transition: background 150ms, color 150ms, border-color 150ms; +} +.docs-sidebar-search:hover { + color: var(--foreground); + border-color: var(--border); +} +.docs-sidebar-search svg { width: 0.875rem; height: 0.875rem; flex-shrink: 0; } +.docs-sidebar-search-label { flex: 1; } +.docs-sidebar-search-kbd { display: inline-flex; gap: 0.2rem; } +.docs-sidebar-search-kbd kbd { + font-family: var(--font-mono); + font-size: 0.62rem; + font-weight: 600; + background: var(--background); + border: 1px solid var(--border); + padding: 0.05rem 0.3rem; + border-radius: 4px; + color: var(--muted-foreground); + line-height: 1; + min-width: 1.1rem; + text-align: center; +} diff --git a/src/ShellDocs.Components/Chrome/PackageSelector.razor b/src/ShellDocs.Components/Chrome/PackageSelector.razor index 65cec79..1f2d29f 100644 --- a/src/ShellDocs.Components/Chrome/PackageSelector.razor +++ b/src/ShellDocs.Components/Chrome/PackageSelector.razor @@ -8,9 +8,8 @@ @Selected.Title - - - + + diff --git a/src/ShellDocs.Components/Chrome/PackageSelector.razor.css b/src/ShellDocs.Components/Chrome/PackageSelector.razor.css index b789d9a..e811883 100644 --- a/src/ShellDocs.Components/Chrome/PackageSelector.razor.css +++ b/src/ShellDocs.Components/Chrome/PackageSelector.razor.css @@ -46,14 +46,17 @@ white-space: nowrap; } -/* Stacked select-style chevrons — fumadocs pattern */ -.pkg-chevrons { +/* Single chevron-down that rotates 180° when the menu opens — matches + the collapsible section toggles in the sidebar. */ +.pkg-chevron { width: 0.9rem; height: 0.9rem; color: var(--muted-foreground); flex-shrink: 0; margin-left: 0.25rem; + transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1); } +.pkg-chevron.open { transform: rotate(180deg); } .pkg-menu { position: absolute; diff --git a/src/ShellDocs.Components/Chrome/SearchDialog.razor b/src/ShellDocs.Components/Chrome/SearchDialog.razor new file mode 100644 index 0000000..4d499fd --- /dev/null +++ b/src/ShellDocs.Components/Chrome/SearchDialog.razor @@ -0,0 +1,208 @@ +@inject SearchState State +@inject SearchIndex Index +@inject NavigationManager Nav +@inject IJSRuntime JS +@implements IDisposable + +@if (State.IsOpen) +{ +
+
+
+ + + Esc +
+ +
+ @if (_results.Count == 0 && !string.IsNullOrWhiteSpace(_query)) + { +
No results for “@_query”.
+ } + else + { + @for (var i = 0; i < _results.Count; i++) + { + var idx = i; + var r = _results[i]; + + } + } +
+ + +
+
+} + +@code { + private string _query = ""; + private int _highlightedIndex; + private List _results = new(); + private ElementReference _inputEl; + private bool _needsFocus; + private DotNetObjectReference? _selfRef; + private IJSObjectReference? _hotkeyDisposer; + + protected override void OnInitialized() + { + State.OnChange += OnStateChange; + Recalc(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _selfRef = DotNetObjectReference.Create(this); + try + { + _hotkeyDisposer = await JS.InvokeAsync("shelldocsSearch.init", _selfRef); + } + catch { } + } + if (_needsFocus) + { + _needsFocus = false; + try { await _inputEl.FocusAsync(); } catch { } + } + } + + [JSInvokable] public void OpenFromJs() => InvokeAsync(() => State.Open()); + + private void OnStateChange() + { + if (State.IsOpen) + { + _query = ""; + _highlightedIndex = 0; + Recalc(); + _needsFocus = true; + } + InvokeAsync(StateHasChanged); + } + + private void OnKey(KeyboardEventArgs e) + { + switch (e.Key) + { + case "Escape": State.Close(); break; + case "ArrowDown": + if (_results.Count > 0) + _highlightedIndex = (_highlightedIndex + 1) % _results.Count; + break; + case "ArrowUp": + if (_results.Count > 0) + _highlightedIndex = (_highlightedIndex - 1 + _results.Count) % _results.Count; + break; + case "Enter": + if (_highlightedIndex >= 0 && _highlightedIndex < _results.Count) + Jump(_results[_highlightedIndex].Entry); + break; + default: + // Any other key = user is typing; rerun search on next binding cycle. + InvokeAsync(Recalc); + break; + } + } + + private void Jump(SearchEntry entry) + { + State.Close(); + Nav.NavigateTo(entry.Url); + } + + private void Recalc() + { + _results = Rank(_query, Index.Entries).Take(30).ToList(); + if (_highlightedIndex >= _results.Count) _highlightedIndex = 0; + } + + /* Naive fuzzy score: substring hit boosts by big margin, then per-word + token overlap. Good enough for the ~100-entry indexes docs sites have. */ + private static IEnumerable Rank(string query, IReadOnlyList entries) + { + if (string.IsNullOrWhiteSpace(query)) + { + return entries.Take(20).Select(e => new Scored(e, 0)); + } + var q = query.Trim().ToLowerInvariant(); + var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return entries + .Select(e => new Scored(e, Score(e, q, tokens))) + .Where(s => s.Score > 0) + .OrderByDescending(s => s.Score); + } + + private static int Score(SearchEntry e, string q, string[] tokens) + { + var title = e.Title.ToLowerInvariant(); + var desc = (e.Description ?? "").ToLowerInvariant(); + var sec = (e.Section ?? "").ToLowerInvariant(); + var score = 0; + // Exact substring in title: massive boost. + if (title.Contains(q)) score += 100; + if (title.StartsWith(q)) score += 50; + if (sec.Contains(q)) score += 20; + if (desc.Contains(q)) score += 10; + // Per-token AND — every token must appear somewhere. + foreach (var t in tokens) + { + if (title.Contains(t)) score += 8; + else if (sec.Contains(t)) score += 4; + else if (desc.Contains(t)) score += 2; + else return 0; // hard reject: token missing + } + // Pages rank slightly higher than headings when otherwise-equal. + if (e.Kind == SearchEntryKind.Page) score += 2; + return score; + } + + private record Scored(SearchEntry Entry, int Score); + + public void Dispose() + { + State.OnChange -= OnStateChange; + _selfRef?.Dispose(); + } +} diff --git a/src/ShellDocs.Components/Chrome/SearchDialog.razor.css b/src/ShellDocs.Components/Chrome/SearchDialog.razor.css new file mode 100644 index 0000000..5832fa3 --- /dev/null +++ b/src/ShellDocs.Components/Chrome/SearchDialog.razor.css @@ -0,0 +1,167 @@ +.search-backdrop { + position: fixed; + inset: 0; + background: rgb(0 0 0 / 0.55); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 90; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 8vh; + animation: search-fade-in 140ms ease; +} +@keyframes search-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.search-panel { + width: min(38rem, calc(100vw - 2rem)); + max-height: 70vh; + background: var(--popover); + color: var(--popover-foreground); + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 4px); + box-shadow: 0 24px 48px -12px rgb(0 0 0 / 0.35); + display: flex; + flex-direction: column; + overflow: hidden; + animation: search-scale-in 160ms cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes search-scale-in { + from { opacity: 0; transform: translateY(-8px) scale(0.98); } + to { opacity: 1; transform: none; } +} + +.search-input-row { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.75rem 0.85rem; + border-bottom: 1px solid var(--border); +} +.search-icon { width: 1rem; height: 1rem; color: var(--muted-foreground); flex-shrink: 0; } +.search-input { + flex: 1; + min-width: 0; + background: transparent; + border: 0; + outline: 0; + color: var(--foreground); + font-family: inherit; + font-size: 0.95rem; + padding: 0.15rem 0; +} +.search-input::placeholder { color: var(--muted-foreground); } +.search-esc { + font-family: var(--font-mono); + font-size: 0.7rem; + padding: 0.15rem 0.45rem; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--muted-foreground); +} + +.search-results { + overflow-y: auto; + padding: 0.35rem 0.35rem; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +.search-empty { + padding: 2rem 1rem; + text-align: center; + color: var(--muted-foreground); + font-size: 0.875rem; +} + +.search-result { + display: flex; + align-items: flex-start; + gap: 0.65rem; + width: 100%; + padding: 0.55rem 0.65rem; + background: transparent; + border: 0; + border-radius: calc(var(--radius) - 3px); + color: var(--foreground); + text-align: left; + cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + transition: background 100ms; +} +.search-result.highlighted { background: var(--accent); } + +.search-result-kind { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.65rem; + height: 1.65rem; + border-radius: 5px; + background: var(--muted); + color: var(--muted-foreground); + flex-shrink: 0; + margin-top: 1px; +} +.search-result.highlighted .search-result-kind { color: var(--foreground); } +.search-result-kind svg { width: 0.85rem; height: 0.85rem; } + +.search-result-body { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; + flex: 1; +} +.search-result-title { + font-weight: 500; + color: var(--foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.search-result-section { + font-size: 0.75rem; + color: var(--muted-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-footer { + display: flex; + gap: 1rem; + padding: 0.55rem 0.85rem; + border-top: 1px solid var(--border); + background: color-mix(in oklch, var(--muted) 40%, transparent); + font-size: 0.72rem; + color: var(--muted-foreground); +} +.search-hint { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.search-footer kbd { + font-family: var(--font-mono); + font-size: 0.68rem; + padding: 0.05rem 0.35rem; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--background); + color: var(--muted-foreground); + min-width: 1.05rem; + line-height: 1; + text-align: center; +} +.search-footer .kbd-icon { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem; + min-width: 0; +} +.search-footer .kbd-icon svg { width: 0.6rem; height: 0.6rem; } diff --git a/src/ShellDocs.Components/Chrome/SearchState.cs b/src/ShellDocs.Components/Chrome/SearchState.cs new file mode 100644 index 0000000..9a5ae9b --- /dev/null +++ b/src/ShellDocs.Components/Chrome/SearchState.cs @@ -0,0 +1,14 @@ +namespace ShellDocs.Components.Chrome; + +/* Global open/close state for . Any component (header chip, + sidebar chip, or the Cmd+K JS shortcut) can call Open(); the dialog itself + subscribes to OnChange to re-render. */ +public class SearchState +{ + public bool IsOpen { get; private set; } + public event Action? OnChange; + + public void Open() { if (!IsOpen) { IsOpen = true; OnChange?.Invoke(); } } + public void Close() { if (IsOpen) { IsOpen = false; OnChange?.Invoke(); } } + public void Toggle() { IsOpen = !IsOpen; OnChange?.Invoke(); } +} diff --git a/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs b/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs new file mode 100644 index 0000000..bb8856e --- /dev/null +++ b/src/ShellDocs.Components/Chrome/SidebarCollapseState.cs @@ -0,0 +1,14 @@ +namespace ShellDocs.Components.Chrome; + +/* Desktop-only collapse state for the Sidebar-variant docs layout. Distinct + from MobileNavState — mobile drawer is temporary/overlay, this is a + persistent "hide the sidebar until I click reopen" toggle. */ +public class SidebarCollapseState +{ + public bool IsCollapsed { get; private set; } + public event Action? OnChange; + + public void Toggle() { IsCollapsed = !IsCollapsed; OnChange?.Invoke(); } + public void Collapse() { if (!IsCollapsed) { IsCollapsed = true; OnChange?.Invoke(); } } + public void Expand() { if (IsCollapsed) { IsCollapsed = false; OnChange?.Invoke(); } } +} diff --git a/src/ShellDocs.Components/Chrome/TableOfContents.razor.css b/src/ShellDocs.Components/Chrome/TableOfContents.razor.css index f2bb079..8e4c87c 100644 --- a/src/ShellDocs.Components/Chrome/TableOfContents.razor.css +++ b/src/ShellDocs.Components/Chrome/TableOfContents.razor.css @@ -9,16 +9,14 @@ font-size: 0.75rem; font-weight: 500; color: var(--muted-foreground); - padding: 2rem 0 0.75rem 0.75rem; - /* Sticks to the top of the scrolling TOC slot; solid background so links - scroll cleanly underneath. A subtle fade below hints at more content. */ + padding: 1rem 0 0.75rem 0.75rem; + /* Sticky at top of the scrolling TOC slot. Solid bg = content hides + cleanly under it (no bleed-through). Bottom border optional; kept + transparent to stay minimal. */ position: sticky; top: 0; z-index: 5; - background: linear-gradient(to bottom, - var(--background) 0%, - var(--background) calc(100% - 8px), - color-mix(in oklch, var(--background) 40%, transparent) 100%); + background: var(--background); } .toc-title svg { width: 0.9rem; height: 0.9rem; } From c93d461ac865b95243eceea4d64919757d135bbd Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 20 Jul 2026 21:52:16 +0200 Subject: [PATCH 07/13] feat: implement in-memory search index for navigation graph, enabling enhanced search capabilities for pages and headings --- src/ShellDocs.Core/SearchIndex.cs | 102 ++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/ShellDocs.Core/SearchIndex.cs diff --git a/src/ShellDocs.Core/SearchIndex.cs b/src/ShellDocs.Core/SearchIndex.cs new file mode 100644 index 0000000..30bff4b --- /dev/null +++ b/src/ShellDocs.Core/SearchIndex.cs @@ -0,0 +1,102 @@ +using System.Text.RegularExpressions; + +namespace ShellDocs.Core; + +/* An in-memory search index built from the navigation graph. Each entry + represents one searchable thing — a page, or a heading within a page. + Client-side fuzzy match runs against Title + Description + Section over the + wire; body-text indexing lands with the search-index.json build step. */ +public sealed class SearchIndex +{ + public IReadOnlyList Entries { get; } + + public SearchIndex(IReadOnlyList entries) => Entries = entries; + + public static SearchIndex FromGraph(NavigationGraph graph) + { + var entries = new List(); + Walk(graph.Root, section: null, entries); + return new SearchIndex(entries); + } + + private static void Walk(NavigationNode node, string? section, List acc) + { + if (node.Kind == NodeKind.Page && !string.IsNullOrEmpty(node.Url)) + { + acc.Add(new SearchEntry( + Url: node.Url, + Title: node.Title, + Description: node.Description, + Section: section, + Kind: SearchEntryKind.Page)); + + // Prefer headings already extracted at render time; otherwise pull + // them from the source markdown ourselves so the index isn't blank + // at startup (headings normally populate only when a page renders). + var headings = node.Headings.Count > 0 + ? node.Headings + : ExtractHeadingsFromFile(node.Path); + foreach (var h in headings.Where(h => h.Level == 2 || h.Level == 3)) + { + acc.Add(new SearchEntry( + Url: node.Url + "#" + h.Id, + Title: h.Text, + Description: null, + Section: node.Title, + Kind: SearchEntryKind.Heading)); + } + } + + var nextSection = node.Kind == NodeKind.Section && !string.IsNullOrEmpty(node.Title) + ? node.Title + : section; + foreach (var child in node.Children) + { + Walk(child, nextSection, acc); + } + } + + private static readonly Regex FencedBlock = new(@"^```[\s\S]*?^```", RegexOptions.Multiline | RegexOptions.Compiled); + private static readonly Regex HeadingLine = new(@"^(#{2,3})\s+(.+?)\s*$", RegexOptions.Multiline | RegexOptions.Compiled); + + private static IReadOnlyList ExtractHeadingsFromFile(string? path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return Array.Empty(); + var text = File.ReadAllText(path); + // Strip fenced code blocks so # inside code doesn't parse as a heading. + text = FencedBlock.Replace(text, ""); + var list = new List(); + foreach (Match m in HeadingLine.Matches(text)) + { + var level = m.Groups[1].Value.Length; + var raw = m.Groups[2].Value.Trim(); + list.Add(new Heading(level, raw, Slugify(raw))); + } + return list; + } + + private static string Slugify(string text) + { + var lowered = text.ToLowerInvariant(); + var sb = new System.Text.StringBuilder(lowered.Length); + var lastDash = false; + foreach (var ch in lowered) + { + if (char.IsLetterOrDigit(ch)) { sb.Append(ch); lastDash = false; } + else if ((ch == ' ' || ch == '-' || ch == '_') && !lastDash) + { + sb.Append('-'); lastDash = true; + } + } + return sb.ToString().Trim('-'); + } +} + +public record SearchEntry( + string Url, + string Title, + string? Description, + string? Section, + SearchEntryKind Kind); + +public enum SearchEntryKind { Page, Heading } From 4f0b2e76d80265c283ab599abf645a3738e50dc1 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 20 Jul 2026 21:52:56 +0200 Subject: [PATCH 08/13] feat: enhance DocsLayout with sidebar variant support, including collapsible sidebar functionality and a floating toolbar for improved navigation experience --- .../Layouts/DocsLayout.razor | 44 ++++++++- .../Layouts/DocsLayout.razor.css | 97 +++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/src/ShellDocs.Components/Layouts/DocsLayout.razor b/src/ShellDocs.Components/Layouts/DocsLayout.razor index 94d679a..0ffb0b6 100644 --- a/src/ShellDocs.Components/Layouts/DocsLayout.razor +++ b/src/ShellDocs.Components/Layouts/DocsLayout.razor @@ -1,13 +1,41 @@ @inherits LayoutComponentBase @inject NavigationManager Nav @inject ShellDocs.Components.Chrome.MobileNavState MobileNav +@inject ShellDocs.Components.Chrome.SidebarCollapseState Collapse +@inject ShellDocsOptions Options +@inject IJSRuntime JS @implements IDisposable @using Microsoft.AspNetCore.Components.Sections -
- +
+ @if (Options.LayoutVariant == DocsLayoutVariant.TopNav) + { + + } + else + { + + } + + @* Floating reopen toolbar — only visible in Sidebar variant when collapsed *@ + @if (Options.LayoutVariant == DocsLayoutVariant.Sidebar && Collapse.IsCollapsed) + { +
+ + +
+ } +
@if (MobileNav.IsOpen) @@ -25,19 +53,31 @@
+ + @code { + private string Variant => Options.LayoutVariant switch + { + DocsLayoutVariant.Sidebar => "sidebar", + _ => "topnav" + }; + protected override void OnInitialized() { MobileNav.OnChange += StateHasChanged; + Collapse.OnChange += StateHasChanged; Nav.LocationChanged += OnRouteChanged; } private void OnRouteChanged(object? sender, Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs e) => MobileNav.Close(); + private Task OpenSearch() => JS.InvokeVoidAsync("shelldocsSearch.open").AsTask(); + public void Dispose() { MobileNav.OnChange -= StateHasChanged; + Collapse.OnChange -= StateHasChanged; Nav.LocationChanged -= OnRouteChanged; } } diff --git a/src/ShellDocs.Components/Layouts/DocsLayout.razor.css b/src/ShellDocs.Components/Layouts/DocsLayout.razor.css index 311b90f..4d48a68 100644 --- a/src/ShellDocs.Components/Layouts/DocsLayout.razor.css +++ b/src/ShellDocs.Components/Layouts/DocsLayout.razor.css @@ -118,3 +118,100 @@ @media (min-width: 1280px) { .docs-toc-slot { display: block; } } + +/* ─────────────────────────────────────────────────────────────────── + Sidebar variant — no top nav, sidebar is the ONLY floating card, + content sits flush against the page bg. shadcn sidebar-04 vibe. + ─────────────────────────────────────────────────────────────────── */ + +.docs-shell-sidebar { background: var(--background); } + +@media (min-width: 1024px) { + .docs-shell-sidebar .docs-body { + padding: 0.85rem 1rem 1rem 0.85rem; + gap: 1rem; + max-width: 1500px; + grid-template-columns: var(--sidebar-width) 1fr; + } + /* Sidebar = the floating card */ + .docs-shell-sidebar .docs-sidebar-slot { + display: flex; + flex-direction: column; + position: sticky; + top: 0.85rem; + height: calc(100vh - 1.85rem); + background: var(--card); + border: 1px solid var(--border); + border-radius: calc(var(--radius) + 2px); + box-shadow: 0 1px 2px rgb(0 0 0 / 0.04); + margin-right: 0; + overflow: hidden; + } + /* DocsSidebar's own
- + @if (ShowSearch) + { + + } @@ -87,6 +90,11 @@ @implements IDisposable @code { + /* Search chip toggle. Marketing pages (HomeLayout) pass false — visitors + there aren't looking to grep docs. Docs pages (DocsLayout TopNav variant) + leave the default true. */ + [Parameter] public bool ShowSearch { get; set; } = true; + private const string DefaultIcon = ""; From 14e517e4f40dbd23fdb92ada51150aabef00085b Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 20 Jul 2026 22:19:14 +0200 Subject: [PATCH 13/13] feat: update DocsHeader component to disable search functionality by setting ShowSearch to false in HomeLayout, streamlining the layout for specific use cases --- src/ShellDocs.Components/Layouts/HomeLayout.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ShellDocs.Components/Layouts/HomeLayout.razor b/src/ShellDocs.Components/Layouts/HomeLayout.razor index 8c7f546..ed9d2c4 100644 --- a/src/ShellDocs.Components/Layouts/HomeLayout.razor +++ b/src/ShellDocs.Components/Layouts/HomeLayout.razor @@ -1,7 +1,7 @@ @inherits LayoutComponentBase
- +
@Body