From e13014392557dc397da0c86241f2891a7c98adcb Mon Sep 17 00:00:00 2001 From: Maggie Moeller Date: Tue, 14 Apr 2026 11:05:42 -0400 Subject: [PATCH 1/3] Add collapsible category submenus to navigation sidebar Group the flat 52-item nav list into 7 collapsible categories (Maps & Scenes, Layers, Visualization, Widgets, Queries, Interaction, Location) with expand/collapse chevrons. Active page's group auto-expands on load, search auto-expands all matching groups. Gradient left border on active item, pinned Home link at top. Pro NavMenu inherits grouping with Pro pages slotted into the same categories. Also fixes UniqueValueRenderers build error (missing ToUpperFirstChar extension). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Pages/UniqueValueRenderers.razor.cs | 2 +- .../Shared/NavMenu.razor | 38 ++++++- .../Shared/NavMenu.razor.cs | 100 +++++++++++++++++- .../wwwroot/css/site.css | 81 ++++++++++++-- .../Shared/ProNavMenu.razor | 38 ++++++- .../Shared/ProNavMenu.razor.cs | 36 +++++++ 6 files changed, 281 insertions(+), 14 deletions(-) diff --git a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Pages/UniqueValueRenderers.razor.cs b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Pages/UniqueValueRenderers.razor.cs index a21a830..ebd0251 100644 --- a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Pages/UniqueValueRenderers.razor.cs +++ b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Pages/UniqueValueRenderers.razor.cs @@ -60,7 +60,7 @@ public partial class UniqueValueRenderers ["proposed"] = new SimpleLineSymbol(new MapColor(192, 192, 192), 1.5, SimpleLineSymbolStyle.Dot) }; private readonly UniqueValueRenderer _uniqueValueRenderer = new(uniqueValueInfos: roadTypes - .Select(r => new UniqueValueInfo(r.Key.ToUpperFirstChar().Replace("_", " "), r.Value, r.Key)) + .Select(r => new UniqueValueInfo(string.Concat(r.Key[0].ToString().ToUpper(), r.Key.AsSpan(1)).Replace("_", " "), r.Value, r.Key)) .ToArray(), field: "highway", defaultLabel: "Service", legendOptions: new UniqueValueRendererLegendOptions("Route Type")); diff --git a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor index a667193..b78039b 100644 --- a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor +++ b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor @@ -9,10 +9,10 @@
\ No newline at end of file diff --git a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor.cs b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor.cs index b9a85f5..ef07b84 100644 --- a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor.cs +++ b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor.cs @@ -22,6 +22,33 @@ public partial class NavMenu ? Pages : Pages.Where(p => p.Title.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); + protected IEnumerable UngroupedPages => FilteredPages.Where(p => + string.IsNullOrEmpty(p.Href) || !PageCategories.ContainsKey(p.Href)); + + protected IEnumerable<(string GroupName, IEnumerable Pages)> GroupedFilteredPages => + GroupOrder + .Select(g => (GroupName: g, Pages: FilteredPages.Where(p => + PageCategories.TryGetValue(p.Href, out var cat) && cat == g))) + .Where(g => g.Pages.Any()); + + protected HashSet ExpandedGroups { get; set; } = new(); + + protected void ToggleGroup(string groupName) + { + if (!ExpandedGroups.Add(groupName)) + { + ExpandedGroups.Remove(groupName); + } + } + + protected bool IsGroupExpanded(string groupName) + { + if (!string.IsNullOrWhiteSpace(SearchText)) + return true; + + return ExpandedGroups.Contains(groupName); + } + protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); @@ -39,9 +66,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (currentPage != string.Empty) { + if (PageCategories.TryGetValue(currentPage, out string? group)) + { + ExpandedGroups.Add(group); + } + await JsRuntime.InvokeVoidAsync("scrollToNav", currentPage); } - + StateHasChanged(); } @@ -136,4 +168,70 @@ await InvokeAsync(async () => new("reverse-geolocator", "GeoLocator", "oi-arrow-circle-bottom") ]; public record PageLink(string Href, string Title, string? IconClass, string? ImageFile = null, bool Pro = false); + + // Category mapping: page href -> group name. Pages not listed appear ungrouped. + protected static readonly Dictionary CorePageCategories = new() + { + ["navigation"] = "Maps & Scenes", + ["scene"] = "Maps & Scenes", + ["basemaps"] = "Maps & Scenes", + ["web-map"] = "Maps & Scenes", + ["web-scene"] = "Maps & Scenes", + + ["feature-layers"] = "Layers", + ["map-image-layers"] = "Layers", + ["vector-layer"] = "Layers", + ["csv-layer"] = "Layers", + ["kmllayers"] = "Layers", + ["georss-layer"] = "Layers", + ["osm-layer"] = "Layers", + ["wcslayers"] = "Layers", + ["wfslayers"] = "Layers", + ["wmslayers"] = "Layers", + ["wmtslayers"] = "Layers", + ["imagerylayer"] = "Layers", + ["imagery-tile-layer"] = "Layers", + + ["labels"] = "Visualization", + ["unique-value"] = "Visualization", + ["marker-rotation"] = "Visualization", + + ["widgets"] = "Widgets", + ["popups"] = "Widgets", + ["popup-actions"] = "Widgets", + ["bookmarks"] = "Widgets", + ["layer-lists"] = "Widgets", + ["basemap-layer-lists"] = "Widgets", + ["measurement-widgets"] = "Widgets", + ["search-multi-source"] = "Widgets", + + ["sql-query"] = "Queries", + ["sql-filter-query"] = "Queries", + ["server-side-queries"] = "Queries", + ["query-related-features"] = "Queries", + ["query-top-features"] = "Queries", + + ["drawing"] = "Interaction", + ["click-to-add"] = "Interaction", + ["many-graphics"] = "Interaction", + ["events"] = "Interaction", + ["reactive-utils"] = "Interaction", + ["hit-tests"] = "Interaction", + ["graphic-tracking"] = "Interaction", + + ["place-selector"] = "Location", + ["service-areas"] = "Location", + ["calculate-geometries"] = "Location", + ["projection"] = "Location", + ["projection-tool"] = "Location", + ["basemap-projections"] = "Location", + ["geometry-methods"] = "Location", + ["locator-methods"] = "Location", + ["reverse-geolocator"] = "Location", + }; + + protected virtual Dictionary PageCategories => CorePageCategories; + + protected static readonly string[] GroupOrder = + ["Maps & Scenes", "Layers", "Visualization", "Widgets", "Queries", "Interaction", "Location"]; } \ No newline at end of file diff --git a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/css/site.css b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/css/site.css index fd39ef2..0461eb1 100644 --- a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/css/site.css +++ b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/wwwroot/css/site.css @@ -770,8 +770,10 @@ a:not([href]):not([class]), a:not([href]):not([class]):hover { color: var(--geoblazor-accent-emphasis); } -.nav-list .nav-list-item:has(.nav-list-link.active) { - background-color: var(--background-grey-3); +.nav-list .nav-list-item:has(> .nav-list-link.active) { + background-color: var(--background-grey-1); + border-left: 3px solid transparent; + border-image: var(--geoblazor-gradient-vertical) 1; } .nav-list .nav-list-item .nav-list-link.active { @@ -786,26 +788,87 @@ a:not([href]):not([class]), a:not([href]):not([class]):hover { background-image: unset; } +/* Pinned top-level items (Home) */ +.nav-list .nav-list-item.nav-pinned { + padding: 0.5rem 0.75rem; + margin-bottom: 0.25rem; + border-bottom: 1px solid var(--background-grey-3); + border-radius: 0; + padding-bottom: 0.75rem; +} + +/* Group headers */ +.nav-list .nav-list-item.nav-group { + padding: 0; + margin-bottom: 0.25rem; + margin-top: 0.25rem; + background-color: transparent; + border-radius: 0; + border-top: 1px solid var(--background-grey-3); +} + +.nav-list .nav-list-item.nav-group:first-of-type { + margin-top: 0.5rem; +} + +.nav-list .nav-list-item.nav-group:hover { + background-color: transparent; +} + .nav-list .nav-list-item .nav-list-expander { - color: var(--geoblazor-secondary); + color: var(--text-emphasis); + cursor: pointer; + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + user-select: none; + border-radius: var(--box-radius); } .nav-list .nav-list-item .nav-list-expander:hover { color: var(--geoblazor-secondary-hover); + background-color: var(--background-grey-1); background-image: unset; } -.nav-list .nav-list-item > .nav-list .nav-list-item .nav-list-link { - color: var(--text-emphasis); - font-size: 1rem; +.nav-group-title { + font-family: 'Aller', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; } .nav-list .nav-list-item .nav-list-expander svg { + width: 12px; + height: 12px; transform: rotate(0); + transition: transform 0.2s ease; + fill: var(--geoblazor-secondary); + flex-shrink: 0; } .nav-list .nav-list-item.active .nav-list-expander svg { transform: rotate(90deg); + fill: var(--geoblazor-accent); +} + +/* Nested sublist */ +.nav-list .nav-sublist { + padding-left: 1rem; + padding-right: 0; + margin-top: 0; + padding-bottom: 0.25rem; +} + +.nav-list .nav-sublist .nav-list-item { + padding: 0.4rem 0.75rem; + margin-bottom: 0.125rem; +} + +.nav-list .nav-list-item > .nav-list .nav-list-item .nav-list-link { + font-size: 0.9rem; } .side-bar a { @@ -1019,8 +1082,10 @@ a:not([href]):not([class]), a:not([href]):not([class]):hover { background-color: var(--background-grey-3); } - .nav-list .nav-list-item:has(.nav-list-link.active) { - background-color: var(--background-grey-4); + .nav-list .nav-list-item:has(> .nav-list-link.active) { + background-color: var(--background-grey-3); + border-left: 3px solid transparent; + border-image: var(--geoblazor-gradient-vertical) 1; } .oi img:not(.pro .oi img) { diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor index 46659e7..0fc73d7 100644 --- a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor @@ -38,10 +38,10 @@
\ No newline at end of file diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs index 478076e..8632b66 100644 --- a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs @@ -46,6 +46,42 @@ public partial class ProNavMenu : NavMenu new("web-style-symbols", "PRO: Web Style Symbols", "oi-brush", null, true), new("highlight-features-by-geometry", "PRO: Highlight by Geometry", "oi-target", null, true) ]; + + protected override Dictionary PageCategories => new(CorePageCategories) + { + // Remapped Core pages + ["pro-widgets"] = "Widgets", + ["pro-bookmarks"] = "Widgets", + + // Pro pages + ["imagery-group-blend"] = "Layers", + ["sketch-query"] = "Queries", + ["edit-feature-data"] = "Interaction", + ["popup-edit"] = "Widgets", + ["update-feature-attributes"] = "Interaction", + ["apply-edits"] = "Interaction", + ["spatial-relationships"] = "Queries", + ["demographic-data"] = "Location", + ["length-and-area"] = "Location", + ["swipe"] = "Widgets", + ["time-slider"] = "Widgets", + ["search-custom-source"] = "Widgets", + ["clustering"] = "Visualization", + ["clustering-popups"] = "Visualization", + ["cluster-pie-charts"] = "Visualization", + ["binning"] = "Visualization", + ["routes"] = "Location", + ["graphics-legend"] = "Visualization", + ["group-layers"] = "Layers", + ["ogc-feature-layers"] = "Layers", + ["wfsutils"] = "Layers", + ["print-widget"] = "Widgets", + ["custom-popup-content"] = "Widgets", + ["geojson-styles"] = "Visualization", + ["web-style-symbols"] = "Visualization", + ["highlight-features-by-geometry"] = "Interaction", + }; + protected override bool CollapseNavMenu { get; set; } = true; private string LowerNavMenuCssClass => _lowerNavMenuOpen ? "" : "lower-collapse"; From efed61af83216ccb18a6b86eac77358d78a430d4 Mon Sep 17 00:00:00 2001 From: Maggie Moeller Date: Tue, 14 Apr 2026 11:46:21 -0400 Subject: [PATCH 2/3] Refactor nav menu: Category on PageLink, constants, DRY templates - Add Category field to PageLink record so category assignment lives with the page definition, not in a separate dictionary - Replace string literals with Categories constants class - Use `p with { Href = href }` in Pro to preserve Category from base pages, removing the per-access dictionary allocation - Extract duplicated NavLink markup into RenderFragment helper in both Core and Pro razor templates Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Shared/NavMenu.razor | 45 ++-- .../Shared/NavMenu.razor.cs | 201 +++++++----------- .../Shared/ProNavMenu.razor | 43 ++-- .../Shared/ProNavMenu.razor.cs | 89 +++----- 4 files changed, 148 insertions(+), 230 deletions(-) diff --git a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor index b78039b..391343b 100644 --- a/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor +++ b/samples/core/dymaptic.GeoBlazor.Core.Sample.Shared/Shared/NavMenu.razor @@ -1,4 +1,4 @@ - - \ No newline at end of file + + +@{ + RenderFragment RenderNavLink(PageLink page) + { + string linkClass = $"nav-list-link{(page.Pro ? " pro" : "")}"; + return@ + @if (page.IconClass is not null) + { + @(page.Title) + } + else + { + @(page.Title)@(page.Title) + } + ; + } +} diff --git a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs index 8632b66..d312bf9 100644 --- a/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs +++ b/samples/pro/dymaptic.GeoBlazor.Pro.Sample.Shared/Shared/ProNavMenu.razor.cs @@ -17,71 +17,36 @@ public partial class ProNavMenu : NavMenu href = $"pro-{href}"; } - return new PageLink(href, p.Title, p.IconClass, p.ImageFile); + return p with { Href = href }; }), - new("imagery-group-blend", "PRO: Imagery Blend", null, "blend.svg", true), - new("sketch-query", "PRO: Sketch Query", "oi-location", null, true), - new("edit-feature-data", "PRO: Edit Data", "oi-map-marker", null, true), - new("popup-edit", "PRO: Popup Edit Data", "oi-pencil", null, true), - new("update-feature-attributes", "PRO: Update Attributes", "oi-brush", null, true), - new("apply-edits", "PRO: Apply Edits", "oi-check", null, true), - new("spatial-relationships", "PRO Relationships", "oi-link-intact", null, true), - new("demographic-data", "PRO: Demographics", "oi-people", null, true), - new("length-and-area", "PRO: Length & Area", "oi-graph", null, true), - new("swipe", "PRO: Swipe Widget", "oi-arrow-thick-left", null, true), - new("time-slider", "PRO: Time Slider", "oi-vertical-align-center", null, true), - new("search-custom-source", "PRO: Custom Search", "oi-magnifying-glass", null, true), - new("clustering", "PRO: Clustering", "oi-fullscreen-exit", null, true), - new("clustering-popups", "PRO: Clustering Popups", "oi-fullscreen-enter", null, true), - new("cluster-pie-charts", "PRO: Pie Chart Clusters", "oi-pie-chart", null, true), - new("binning", "PRO: Binning", "oi-grid-three-up", null, true), - new("routes", "PRO: Routes", "oi-transfer", null, true), - new("graphics-legend", "PRO: Graphics Legend", "oi-list-rich", null, true), - new("group-layers", "PRO: Group Layers", null, "groupLayer.svg", true), - new("ogc-feature-layers", "PRO: OGC Feature Layers", "oi-layers", null, true), - new("wfsutils", "PRO: WFS Utils", "oi-wrench", null, true), - new("print-widget", "PRO: Print Widgets", "oi-print", null, true), - new("custom-popup-content", "PRO: Custom Popup Content", null, "customPopup.svg", true), - new("geojson-styles", "PRO: GeoJSON Styles", "oi-brush", null, true), - new("web-style-symbols", "PRO: Web Style Symbols", "oi-brush", null, true), - new("highlight-features-by-geometry", "PRO: Highlight by Geometry", "oi-target", null, true) + new("imagery-group-blend", "PRO: Imagery Blend", null, "blend.svg", true, Categories.Layers), + new("sketch-query", "PRO: Sketch Query", "oi-location", null, true, Categories.Queries), + new("edit-feature-data", "PRO: Edit Data", "oi-map-marker", null, true, Categories.Interaction), + new("popup-edit", "PRO: Popup Edit Data", "oi-pencil", null, true, Categories.Widgets), + new("update-feature-attributes", "PRO: Update Attributes", "oi-brush", null, true, Categories.Interaction), + new("apply-edits", "PRO: Apply Edits", "oi-check", null, true, Categories.Interaction), + new("spatial-relationships", "PRO Relationships", "oi-link-intact", null, true, Categories.Queries), + new("demographic-data", "PRO: Demographics", "oi-people", null, true, Categories.Location), + new("length-and-area", "PRO: Length & Area", "oi-graph", null, true, Categories.Location), + new("swipe", "PRO: Swipe Widget", "oi-arrow-thick-left", null, true, Categories.Widgets), + new("time-slider", "PRO: Time Slider", "oi-vertical-align-center", null, true, Categories.Widgets), + new("search-custom-source", "PRO: Custom Search", "oi-magnifying-glass", null, true, Categories.Widgets), + new("clustering", "PRO: Clustering", "oi-fullscreen-exit", null, true, Categories.Visualization), + new("clustering-popups", "PRO: Clustering Popups", "oi-fullscreen-enter", null, true, Categories.Visualization), + new("cluster-pie-charts", "PRO: Pie Chart Clusters", "oi-pie-chart", null, true, Categories.Visualization), + new("binning", "PRO: Binning", "oi-grid-three-up", null, true, Categories.Visualization), + new("routes", "PRO: Routes", "oi-transfer", null, true, Categories.Location), + new("graphics-legend", "PRO: Graphics Legend", "oi-list-rich", null, true, Categories.Visualization), + new("group-layers", "PRO: Group Layers", null, "groupLayer.svg", true, Categories.Layers), + new("ogc-feature-layers", "PRO: OGC Feature Layers", "oi-layers", null, true, Categories.Layers), + new("wfsutils", "PRO: WFS Utils", "oi-wrench", null, true, Categories.Layers), + new("print-widget", "PRO: Print Widgets", "oi-print", null, true, Categories.Widgets), + new("custom-popup-content", "PRO: Custom Popup Content", null, "customPopup.svg", true, Categories.Widgets), + new("geojson-styles", "PRO: GeoJSON Styles", "oi-brush", null, true, Categories.Visualization), + new("web-style-symbols", "PRO: Web Style Symbols", "oi-brush", null, true, Categories.Visualization), + new("highlight-features-by-geometry", "PRO: Highlight by Geometry", "oi-target", null, true, Categories.Interaction) ]; - protected override Dictionary PageCategories => new(CorePageCategories) - { - // Remapped Core pages - ["pro-widgets"] = "Widgets", - ["pro-bookmarks"] = "Widgets", - - // Pro pages - ["imagery-group-blend"] = "Layers", - ["sketch-query"] = "Queries", - ["edit-feature-data"] = "Interaction", - ["popup-edit"] = "Widgets", - ["update-feature-attributes"] = "Interaction", - ["apply-edits"] = "Interaction", - ["spatial-relationships"] = "Queries", - ["demographic-data"] = "Location", - ["length-and-area"] = "Location", - ["swipe"] = "Widgets", - ["time-slider"] = "Widgets", - ["search-custom-source"] = "Widgets", - ["clustering"] = "Visualization", - ["clustering-popups"] = "Visualization", - ["cluster-pie-charts"] = "Visualization", - ["binning"] = "Visualization", - ["routes"] = "Location", - ["graphics-legend"] = "Visualization", - ["group-layers"] = "Layers", - ["ogc-feature-layers"] = "Layers", - ["wfsutils"] = "Layers", - ["print-widget"] = "Widgets", - ["custom-popup-content"] = "Widgets", - ["geojson-styles"] = "Visualization", - ["web-style-symbols"] = "Visualization", - ["highlight-features-by-geometry"] = "Interaction", - }; - protected override bool CollapseNavMenu { get; set; } = true; private string LowerNavMenuCssClass => _lowerNavMenuOpen ? "" : "lower-collapse"; From e3bb07846fa5996d66421b6f4caa40644ea1e51b Mon Sep 17 00:00:00 2001 From: Maggie Moeller Date: Tue, 14 Apr 2026 12:21:49 -0400 Subject: [PATCH 3/3] Fix first-group selector and improve expander accessibility - Replace :first-of-type with .nav-pinned + .nav-group sibling selector (the old selector never matched because Home's
  • was the first of its type) - Change group expander from
    to @if (expanded) {