-
- @(IsSidebarOpen ? "Close" : "Open") Sidebar
-
-
-
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Blocks.razor b/NET10/BlazorInteractiveServer/Components/Pages/Blocks.razor
index 274a8fa..7b814fb 100644
--- a/NET10/BlazorInteractiveServer/Components/Pages/Blocks.razor
+++ b/NET10/BlazorInteractiveServer/Components/Pages/Blocks.razor
@@ -1,5 +1,5 @@
@page "/blocks"
-@layout BlazorInteractiveServer.Components.Layout.DashboardLayout
+@layout BlazorInteractiveServer.Components.Layout.BlocksLayout
@rendermode InteractiveServer
@using BlazorInteractiveServer.Components.UI
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/DataPickerDoc.razor b/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/DataPickerDoc.razor
new file mode 100644
index 0000000..0b55c72
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/DataPickerDoc.razor
@@ -0,0 +1,122 @@
+@page "/components/form/data-picker"
+@layout BlazorInteractiveServer.Components.Layout.DashboardLayout
+@rendermode InteractiveServer
+@using BlazorInteractiveServer.Components.UI
+
+DataPicker | ShellUI
+
+
+
+
DataPicker
+
Generic typed picker with search, keyboard navigation, and templated rendering. The typed sibling of Combobox.
+
+
+
+ Installation
+
+ dotnet shellui add data-picker
+
+
+
+
+
+ Usage
+
+ Basic — string display, real domain type
+
+
+ @if (_picked is not null)
+ {
+
Picked: @_picked.Name · @_picked.Category
+ }
+
+
+ With templates — rich rows
+
+
+
+
+ @context.Name
+ @context.Category
+
+
+
+
+
+ Disabled
+
+
+
+
+
+
+ API Reference
+
+
+
+
+ Parameter
+ Type
+ Default
+ Description
+
+
+
+ Items IEnumerable<TItem> — Source list (required)
+ Value TItem? null Currently selected item
+ ValueChanged EventCallback<TItem?> — Fired when selection changes
+ KeySelector Func<TItem, TKey> — Extract stable identity (required)
+ DisplaySelector Func<TItem, string>? ToString Default row display
+ SearchPredicate Func<TItem, string, bool>? DisplaySelector Contains Custom filter
+ SelectedTemplate RenderFragment<TItem>? — Custom render for selected value in the trigger
+ OptionTemplate RenderFragment<TItem>? — Custom render for each option row
+ Placeholder string "Select..." Trigger placeholder
+ Disabled bool false Disables the trigger
+
+
+
+
+
+
+ Keyboard
+
+ ↑ / ↓ move highlighted row
+ Enter select highlighted row
+ Esc close
+
+
+
+
+@code {
+ private record Framework(string Id, string Name, string Category);
+ private List _frameworks = new()
+ {
+ new("next", "Next.js", "React"),
+ new("sveltekit", "SvelteKit", "Svelte"),
+ new("nuxt", "Nuxt.js", "Vue"),
+ new("remix", "Remix", "React"),
+ new("astro", "Astro", "Multi"),
+ new("qwik", "Qwik", "Fresh"),
+ new("solidstart", "SolidStart", "Solid"),
+ new("blazor", "Blazor", ".NET"),
+ };
+ private Framework? _picked;
+ private Framework? _pickedTemplated;
+}
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/MultiSelectDoc.razor b/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/MultiSelectDoc.razor
new file mode 100644
index 0000000..0e2fa20
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/MultiSelectDoc.razor
@@ -0,0 +1,95 @@
+@page "/components/form/multi-select"
+@layout BlazorInteractiveServer.Components.Layout.DashboardLayout
+@rendermode InteractiveServer
+@using BlazorInteractiveServer.Components.UI
+
+MultiSelect | ShellUI
+
+
+
+
MultiSelect
+
Generic multi-value picker. Renders selected items as chips with inline remove; toggle rows in the popover to add/remove.
+
+
+
+ Installation
+
+ dotnet shellui add multi-select
+
+
+
+
+
+ Usage
+
+ Basic
+
+
+
Selected: @_picked.Count
+
+
+ With option template
+
+
+
+
+ @context.Name
+ @context.Kind
+
+
+
+
+
+
+
+ API Reference
+
+
+
+
+ Parameter
+ Type
+ Default
+ Description
+
+
+
+ Items IEnumerable<TItem> — Source list (required)
+ Values List<TItem> empty Currently selected items
+ ValuesChanged EventCallback<List<TItem>> — Fired when selection changes
+ KeySelector Func<TItem, TKey> — Extract stable identity (required)
+ ChipTemplate RenderFragment<TItem>? — Custom render for chips in the trigger
+ OptionTemplate RenderFragment<TItem>? — Custom render for each option row
+ Disabled bool false Disables the trigger
+
+
+
+
+
+
+@code {
+ private record Tech(string Id, string Name, string Kind);
+ private List _stack = new()
+ {
+ new("blazor", "Blazor", "Framework"),
+ new("tailwind", "Tailwind CSS", "Styling"),
+ new("net10", ".NET 10", "Runtime"),
+ new("csharp14", "C# 14", "Language"),
+ new("apex", "ApexCharts", "Charts"),
+ new("shadcn", "shadcn/ui", "Inspiration"),
+ };
+ private List _picked = new();
+ private List _pickedRich = new();
+}
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/TagInputDoc.razor b/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/TagInputDoc.razor
new file mode 100644
index 0000000..cc31e09
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/Pages/Components/Form/TagInputDoc.razor
@@ -0,0 +1,76 @@
+@page "/components/form/tag-input"
+@layout BlazorInteractiveServer.Components.Layout.DashboardLayout
+@rendermode InteractiveServer
+@using BlazorInteractiveServer.Components.UI
+
+TagInput | ShellUI
+
+
+
+
TagInput
+
Free-form tag entry. Enter to confirm, Backspace on empty removes the last, and pasting comma/newline-separated text bulk-adds.
+
+
+
+ Installation
+
+ dotnet shellui add tag-input
+
+
+
+
+
+ Usage
+
+ Basic
+
+
+
Current: [@string.Join(", ", _tags)]
+
+
+ Max 5 tags, allow duplicates
+
+
+
@_tagsCapped.Count / 5
+
+
+ Disabled
+
+
+
+
+
+
+ API Reference
+
+
+
+
+ Parameter
+ Type
+ Default
+ Description
+
+
+
+ Tags List<string> empty Current tags
+ TagsChanged EventCallback<List<string>> — Fired when tags change
+ Placeholder string "Add tag..." Placeholder while empty
+ MaxTags int? null Cap the number of tags
+ AllowDuplicates bool false Allow identical tags (case-insensitive by default)
+ Separators string[] ",", ";", "\t", "\n" Split chars for paste-to-bulk-add
+ Disabled bool false Disables input and remove buttons
+
+
+
+
+
+
+@code {
+ private List _tags = new() { "blazor", "shellui" };
+ private List _tagsCapped = new();
+}
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor b/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor
index b731935..9d08cb4 100644
--- a/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor
+++ b/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor
@@ -17,10 +17,13 @@
@@ -39,6 +42,7 @@
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Components/Overlay/CommandPaletteDoc.razor b/NET10/BlazorInteractiveServer/Components/Pages/Components/Overlay/CommandPaletteDoc.razor
new file mode 100644
index 0000000..6061e91
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/Pages/Components/Overlay/CommandPaletteDoc.razor
@@ -0,0 +1,73 @@
+@page "/components/overlay/command-palette"
+@layout BlazorInteractiveServer.Components.Layout.DashboardLayout
+@rendermode InteractiveServer
+@using BlazorInteractiveServer.Components.UI
+@using BlazorInteractiveServer.Components.Models
+
+CommandPalette | ShellUI
+
+
+
+
CommandPalette
+
Wraps Command with a global keyboard shortcut (default Ctrl +K / ⌘K ).
+
+
+
+ Installation
+
+ dotnet shellui add command-palette
+
+
+ Auto-installs the command dependency.
+
+
+
+ Try it
+ Press Ctrl + K (or ⌘K on Mac) to open the palette. Type to filter, Enter selects, Esc closes.
+
+
+
+ @if (_last is not null)
+ {
+
+ Last selected: @_last
+
+ }
+
+
+
+ API Reference
+
+
+
+
+ Parameter
+ Type
+ Default
+ Description
+
+
+
+ Commands List<CommandItem> empty Commands available in the palette
+ CommandSelected EventCallback<CommandItem> — Fired when the user picks a command
+ HotkeyChar string "k" Hotkey character
+ UseCtrl bool true Require Ctrl
+ UseMeta bool true Require ⌘ (Meta) — either Ctrl or Meta triggers
+ UseShift / UseAlt bool false Require Shift / Alt
+
+
+
+
+
+
+@code {
+ private List _commands = new()
+ {
+ new() { Title = "Go to Home", Description = "Navigate to the home page", Shortcut = "Ctrl+H" },
+ new() { Title = "Toggle Theme", Description = "Switch between light and dark mode", Shortcut = "Ctrl+T" },
+ new() { Title = "Search Components", Description = "Open component search" },
+ new() { Title = "Open Settings", Description = "Application preferences" },
+ new() { Title = "Sign Out", Description = "End your session" },
+ };
+ private string? _last;
+}
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Home.razor b/NET10/BlazorInteractiveServer/Components/Pages/Home.razor
index 3fc6941..c0ede4d 100644
--- a/NET10/BlazorInteractiveServer/Components/Pages/Home.razor
+++ b/NET10/BlazorInteractiveServer/Components/Pages/Home.razor
@@ -1,5 +1,6 @@
@page "/"
-@layout BlazorInteractiveServer.Components.Layout.DashboardLayout
+@layout BlazorInteractiveServer.Components.Layout.MainLayout
+@rendermode InteractiveServer
@using BlazorInteractiveServer.Components.UI
@using BlazorInteractiveServer.Components.Models
@using BlazorInteractiveServer.Components.Demo
diff --git a/NET10/BlazorInteractiveServer/Components/UI/AppSidebar.razor b/NET10/BlazorInteractiveServer/Components/UI/AppSidebar.razor
index 76eb0f0..f7a04e4 100644
--- a/NET10/BlazorInteractiveServer/Components/UI/AppSidebar.razor
+++ b/NET10/BlazorInteractiveServer/Components/UI/AppSidebar.razor
@@ -220,10 +220,13 @@
new NavItem { Title = "Overview", Url = "/components", IconClass = "fa-solid fa-layer-group" },
new NavItem { Title = "Button", Url = "/components/form/button", IconClass = "fa-solid fa-square" },
new NavItem { Title = "Checkbox", Url = "/components/form/checkbox", IconClass = "fa-solid fa-square-check" },
+ new NavItem { Title = "DataPicker", Url = "/components/form/data-picker", IconClass = "fa-solid fa-list-check" },
new NavItem { Title = "Input", Url = "/components/form/input", IconClass = "fa-solid fa-keyboard" },
new NavItem { Title = "Label", Url = "/components/form/label", IconClass = "fa-solid fa-heading" },
+ new NavItem { Title = "MultiSelect", Url = "/components/form/multi-select", IconClass = "fa-solid fa-layer-group" },
new NavItem { Title = "Select", Url = "/components/form/select", IconClass = "fa-solid fa-caret-down" },
new NavItem { Title = "Switch", Url = "/components/form/switch", IconClass = "fa-solid fa-toggle-on" },
+ new NavItem { Title = "TagInput", Url = "/components/form/tag-input", IconClass = "fa-solid fa-tags" },
new NavItem { Title = "Textarea", Url = "/components/form/textarea", IconClass = "fa-solid fa-align-left" },
new NavItem { Title = "Alert", Url = "/components/data-display/alert", IconClass = "fa-solid fa-triangle-exclamation" },
new NavItem { Title = "Avatar", Url = "/components/data-display/avatar", IconClass = "fa-solid fa-user" },
@@ -233,6 +236,7 @@
new NavItem { Title = "Accordion", Url = "/components/data-display/accordion", IconClass = "fa-solid fa-bars-staggered" },
new NavItem { Title = "Progress", Url = "/components/data-display/progress", IconClass = "fa-solid fa-bars-progress" },
new NavItem { Title = "Tabs", Url = "/components/data-display/tabs", IconClass = "fa-solid fa-folder" },
+ new NavItem { Title = "CommandPalette", Url = "/components/overlay/command-palette", IconClass = "fa-solid fa-terminal" },
new NavItem { Title = "Dialog", Url = "/components/overlay/dialog", IconClass = "fa-solid fa-window-maximize" }
};
diff --git a/NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor b/NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor
new file mode 100644
index 0000000..c6126c7
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor
@@ -0,0 +1,63 @@
+@namespace BlazorInteractiveServer.Components.UI
+@using Microsoft.JSInterop
+@using BlazorInteractiveServer.Components.Models
+@implements IAsyncDisposable
+@inject IJSRuntime JS
+
+
+
+@code {
+ [Parameter] public List Commands { get; set; } = new();
+ [Parameter] public EventCallback CommandSelected { get; set; }
+ [Parameter] public string Placeholder { get; set; } = "Type a command or search...";
+
+ [Parameter] public string HotkeyChar { get; set; } = "k";
+ [Parameter] public bool UseCtrl { get; set; } = true;
+ [Parameter] public bool UseMeta { get; set; } = true;
+ [Parameter] public bool UseShift { get; set; }
+ [Parameter] public bool UseAlt { get; set; }
+
+ private bool _isOpen;
+ private DotNetObjectReference? _selfRef;
+ private readonly string _handle = Guid.NewGuid().ToString("N");
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender) return;
+ _selfRef = DotNetObjectReference.Create(this);
+ try
+ {
+ await JS.InvokeVoidAsync("ShellUI.registerShortcut", _handle, HotkeyChar, UseCtrl, UseMeta, UseShift, UseAlt, _selfRef);
+ }
+ catch { }
+ }
+
+ [JSInvokable]
+ public Task OnShortcut()
+ {
+ _isOpen = !_isOpen;
+ StateHasChanged();
+ return Task.CompletedTask;
+ }
+
+ private Task OnIsOpenChanged(bool open)
+ {
+ _isOpen = open;
+ return Task.CompletedTask;
+ }
+
+ private async Task OnCommandSelected(CommandItem item)
+ {
+ await CommandSelected.InvokeAsync(item);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try { await JS.InvokeVoidAsync("ShellUI.unregisterShortcut", _handle); } catch { }
+ _selfRef?.Dispose();
+ }
+}
diff --git a/NET10/BlazorInteractiveServer/Components/UI/DataPicker.razor b/NET10/BlazorInteractiveServer/Components/UI/DataPicker.razor
new file mode 100644
index 0000000..fe40efb
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/UI/DataPicker.razor
@@ -0,0 +1,169 @@
+@namespace BlazorInteractiveServer.Components.UI
+@typeparam TItem
+@typeparam TKey where TKey : notnull
+
+
+
+
+ @if (Value is not null && SelectedTemplate is not null)
+ {
+ @SelectedTemplate(Value)
+ }
+ else if (Value is not null)
+ {
+ @DisplayFor(Value)
+ }
+ else
+ {
+ @Placeholder
+ }
+
+
+
+
+
+
+ @if (IsOpen)
+ {
+
+
+
+ @{
+ var filtered = Filter().ToList();
+ }
+ @if (filtered.Count == 0)
+ {
+
@EmptyText
+ }
+ else
+ {
+ for (var i = 0; i < filtered.Count; i++)
+ {
+ var item = filtered[i];
+ var isSelected = Value is not null && KeyFor(item).Equals(KeyFor(Value));
+ var isHighlighted = i == _highlightedIndex;
+ var localIdx = i;
+
SelectItem(item)"
+ @onmouseenter="() => _highlightedIndex = localIdx"
+ class="@Shell.Cn("relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none", isHighlighted ? "bg-accent text-accent-foreground" : "", isSelected && !isHighlighted ? "bg-accent/50" : "")">
+ @if (OptionTemplate is not null)
+ {
+ @OptionTemplate(item)
+ }
+ else
+ {
+ @DisplayFor(item)
+ }
+ @if (isSelected)
+ {
+
+
+
+
+
+ }
+
+ }
+ }
+
+
+ }
+
+
+@if (IsOpen)
+{
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IEnumerable Items { get; set; } = Array.Empty();
+ [Parameter] public TItem? Value { get; set; }
+ [Parameter] public EventCallback ValueChanged { get; set; }
+ [Parameter, EditorRequired] public Func KeySelector { get; set; } = default!;
+ [Parameter] public Func? DisplaySelector { get; set; }
+ [Parameter] public Func? SearchPredicate { get; set; }
+ [Parameter] public RenderFragment? SelectedTemplate { get; set; }
+ [Parameter] public RenderFragment? OptionTemplate { get; set; }
+ [Parameter] public string Placeholder { get; set; } = "Select...";
+ [Parameter] public string SearchPlaceholder { get; set; } = "Search...";
+ [Parameter] public string EmptyText { get; set; } = "No results found.";
+ [Parameter] public bool Disabled { get; set; }
+ [Parameter] public string? Class { get; set; }
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary? AdditionalAttributes { get; set; }
+
+ private bool IsOpen { get; set; }
+ private string _searchQuery = "";
+ private int _highlightedIndex;
+ private ElementReference _searchInput;
+ private bool _shouldFocus;
+
+ private string DisplayFor(TItem item) => DisplaySelector?.Invoke(item) ?? item?.ToString() ?? "";
+ private TKey KeyFor(TItem item) => KeySelector(item);
+
+ private IEnumerable Filter()
+ {
+ if (string.IsNullOrWhiteSpace(_searchQuery)) return Items;
+ var q = _searchQuery;
+ if (SearchPredicate is not null) return Items.Where(i => SearchPredicate(i, q));
+ return Items.Where(i => DisplayFor(i).Contains(q, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private void Toggle()
+ {
+ if (Disabled) return;
+ IsOpen = !IsOpen;
+ if (IsOpen)
+ {
+ _searchQuery = "";
+ _highlightedIndex = 0;
+ _shouldFocus = true;
+ }
+ }
+
+ private void Close() => IsOpen = false;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (_shouldFocus)
+ {
+ _shouldFocus = false;
+ try { await _searchInput.FocusAsync(); } catch { }
+ }
+ }
+
+ private async Task SelectItem(TItem item)
+ {
+ Value = item;
+ IsOpen = false;
+ await ValueChanged.InvokeAsync(item);
+ }
+
+ private async Task OnKeyDown(KeyboardEventArgs e)
+ {
+ var filtered = Filter().ToList();
+ if (e.Key == "Escape") { Close(); return; }
+ if (e.Key == "ArrowDown") { _highlightedIndex = Math.Min(_highlightedIndex + 1, filtered.Count - 1); return; }
+ if (e.Key == "ArrowUp") { _highlightedIndex = Math.Max(_highlightedIndex - 1, 0); return; }
+ if (e.Key == "Enter" && filtered.Count > 0)
+ {
+ var idx = Math.Clamp(_highlightedIndex, 0, filtered.Count - 1);
+ await SelectItem(filtered[idx]);
+ }
+ }
+}
diff --git a/NET10/BlazorInteractiveServer/Components/UI/MultiSelect.razor b/NET10/BlazorInteractiveServer/Components/UI/MultiSelect.razor
new file mode 100644
index 0000000..8750345
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/UI/MultiSelect.razor
@@ -0,0 +1,198 @@
+@namespace BlazorInteractiveServer.Components.UI
+@typeparam TItem
+@typeparam TKey where TKey : notnull
+
+
+
+
+ @if (Values.Count == 0)
+ {
+
@Placeholder
+ }
+ else
+ {
+ foreach (var item in Values)
+ {
+ var chip = item;
+
+ @if (ChipTemplate is not null)
+ {
+ @ChipTemplate(chip)
+ }
+ else
+ {
+ @DisplayFor(chip)
+ }
+ RemoveItem(chip)">
+
+
+
+
+
+ }
+ }
+
+
+
+
+
+
+ @if (IsOpen)
+ {
+
+
+
+ @{
+ var filtered = Filter().ToList();
+ }
+ @if (filtered.Count == 0)
+ {
+
@EmptyText
+ }
+ else
+ {
+ for (var i = 0; i < filtered.Count; i++)
+ {
+ var item = filtered[i];
+ var isSelected = Values.Any(v => KeyFor(v).Equals(KeyFor(item)));
+ var isHighlighted = i == _highlightedIndex;
+ var localIdx = i;
+
ToggleItem(item)"
+ @onmouseenter="() => _highlightedIndex = localIdx"
+ class="@Shell.Cn("relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none", isHighlighted ? "bg-accent text-accent-foreground" : "")">
+
+ @if (isSelected)
+ {
+
+
+
+ }
+
+ @if (OptionTemplate is not null)
+ {
+ @OptionTemplate(item)
+ }
+ else
+ {
+ @DisplayFor(item)
+ }
+
+ }
+ }
+
+
+ }
+
+
+@if (IsOpen)
+{
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IEnumerable Items { get; set; } = Array.Empty();
+ [Parameter] public List Values { get; set; } = new();
+ [Parameter] public EventCallback> ValuesChanged { get; set; }
+ [Parameter, EditorRequired] public Func KeySelector { get; set; } = default!;
+ [Parameter] public Func? DisplaySelector { get; set; }
+ [Parameter] public Func? SearchPredicate { get; set; }
+ [Parameter] public RenderFragment? ChipTemplate { get; set; }
+ [Parameter] public RenderFragment? OptionTemplate { get; set; }
+ [Parameter] public string Placeholder { get; set; } = "Select...";
+ [Parameter] public string SearchPlaceholder { get; set; } = "Search...";
+ [Parameter] public string EmptyText { get; set; } = "No results found.";
+ [Parameter] public bool Disabled { get; set; }
+ [Parameter] public string? Class { get; set; }
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary? AdditionalAttributes { get; set; }
+
+ private bool IsOpen { get; set; }
+ private string _searchQuery = "";
+ private int _highlightedIndex;
+ private ElementReference _searchInput;
+ private bool _shouldFocus;
+
+ private string DisplayFor(TItem item) => DisplaySelector?.Invoke(item) ?? item?.ToString() ?? "";
+ private TKey KeyFor(TItem item) => KeySelector(item);
+
+ private IEnumerable Filter()
+ {
+ if (string.IsNullOrWhiteSpace(_searchQuery)) return Items;
+ var q = _searchQuery;
+ if (SearchPredicate is not null) return Items.Where(i => SearchPredicate(i, q));
+ return Items.Where(i => DisplayFor(i).Contains(q, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private void Toggle()
+ {
+ if (Disabled) return;
+ IsOpen = !IsOpen;
+ if (IsOpen)
+ {
+ _searchQuery = "";
+ _highlightedIndex = 0;
+ _shouldFocus = true;
+ }
+ }
+
+ private void Close() => IsOpen = false;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (_shouldFocus)
+ {
+ _shouldFocus = false;
+ try { await _searchInput.FocusAsync(); } catch { }
+ }
+ }
+
+ private async Task ToggleItem(TItem item)
+ {
+ var key = KeyFor(item);
+ var existing = Values.FirstOrDefault(v => KeyFor(v).Equals(key));
+ var next = new List(Values);
+ if (existing is not null && !existing.Equals(default(TItem))) next.Remove(existing);
+ else next.Add(item);
+ Values = next;
+ await ValuesChanged.InvokeAsync(next);
+ }
+
+ private async Task RemoveItem(TItem item)
+ {
+ var next = Values.Where(v => !KeyFor(v).Equals(KeyFor(item))).ToList();
+ Values = next;
+ await ValuesChanged.InvokeAsync(next);
+ }
+
+ private async Task OnKeyDown(KeyboardEventArgs e)
+ {
+ var filtered = Filter().ToList();
+ if (e.Key == "Escape") { Close(); return; }
+ if (e.Key == "ArrowDown") { _highlightedIndex = Math.Min(_highlightedIndex + 1, filtered.Count - 1); return; }
+ if (e.Key == "ArrowUp") { _highlightedIndex = Math.Max(_highlightedIndex - 1, 0); return; }
+ if (e.Key == "Enter" && filtered.Count > 0)
+ {
+ var idx = Math.Clamp(_highlightedIndex, 0, filtered.Count - 1);
+ await ToggleItem(filtered[idx]);
+ }
+ }
+}
diff --git a/NET10/BlazorInteractiveServer/Components/UI/Sidebar.razor b/NET10/BlazorInteractiveServer/Components/UI/Sidebar.razor
index e0000fd..35675a5 100644
--- a/NET10/BlazorInteractiveServer/Components/UI/Sidebar.razor
+++ b/NET10/BlazorInteractiveServer/Components/UI/Sidebar.razor
@@ -17,7 +17,7 @@
@ChildContent
}
-else if (SidebarProvider?.IsMobile == true)
+else if (SidebarProvider?.IsMobile == true && !Inline)
{
@if (SidebarProvider.MobileOpen)
{
@@ -68,6 +68,10 @@ else
[Parameter] public SidebarSide Side { get; set; } = SidebarSide.Left;
[Parameter] public SidebarVariant Variant { get; set; } = SidebarVariant.Sidebar;
[Parameter] public SidebarCollapsible Collapsible { get; set; } = SidebarCollapsible.Offcanvas;
+ /// When true, the sidebar positions itself absolutely (not fixed) so it stays inside
+ /// a bounded parent container. Parent must be `position: relative` with a set height.
+ /// Intended for inline demos and previews; production sidebars should leave this false.
+ [Parameter] public bool Inline { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
@@ -131,12 +135,15 @@ else
// Tailwind classes (no CSS variable arbitrary values)
private string GapClass => Shell.Cn(
- "relative h-svh bg-transparent transition-[width] ease-linear duration-200",
+ "relative bg-transparent transition-[width] ease-linear duration-200",
+ Inline ? "h-full" : "h-svh",
Side == SidebarSide.Right ? "rotate-180" : null
);
private string FixedClass => Shell.Cn(
- "fixed inset-y-0 z-10 hidden h-svh transition-[left,right,width] ease-linear duration-200 md:flex",
+ Inline ? "absolute" : "fixed",
+ "inset-y-0 z-10 hidden transition-[left,right,width] ease-linear duration-200 md:flex",
+ Inline ? "h-full" : "h-svh",
IsFloatingOrInset ? "p-2" : null,
!IsFloatingOrInset && Side == SidebarSide.Left ? "border-r" : null,
!IsFloatingOrInset && Side == SidebarSide.Right ? "border-l" : null,
diff --git a/NET10/BlazorInteractiveServer/Components/UI/TagInput.razor b/NET10/BlazorInteractiveServer/Components/UI/TagInput.razor
new file mode 100644
index 0000000..700cf9e
--- /dev/null
+++ b/NET10/BlazorInteractiveServer/Components/UI/TagInput.razor
@@ -0,0 +1,95 @@
+@namespace BlazorInteractiveServer.Components.UI
+
+
+ @foreach (var tag in Tags)
+ {
+ var chip = tag;
+
+ @chip
+ RemoveTag(chip)">
+
+
+
+
+
+ }
+
+
+
+@code {
+ [Parameter] public List
Tags { get; set; } = new();
+ [Parameter] public EventCallback> TagsChanged { get; set; }
+ [Parameter] public string Placeholder { get; set; } = "Add tag...";
+ [Parameter] public bool Disabled { get; set; }
+ [Parameter] public int? MaxTags { get; set; }
+ [Parameter] public bool AllowDuplicates { get; set; }
+ [Parameter] public string[] Separators { get; set; } = new[] { ",", ";", "\t", "\n" };
+ [Parameter] public string? Class { get; set; }
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary? AdditionalAttributes { get; set; }
+
+ private string _current = "";
+ private ElementReference _input;
+
+ private async Task FocusInput()
+ {
+ if (!Disabled)
+ {
+ try { await _input.FocusAsync(); } catch { }
+ }
+ }
+
+ private async Task OnKeyDown(KeyboardEventArgs e)
+ {
+ if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_current))
+ {
+ await AddTag(_current);
+ }
+ else if (e.Key == "Backspace" && string.IsNullOrEmpty(_current) && Tags.Count > 0)
+ {
+ await RemoveTag(Tags[^1]);
+ }
+ }
+
+ private async Task OnPaste(ClipboardEventArgs e)
+ {
+ if (string.IsNullOrEmpty(_current)) return;
+ var candidates = _current.Split(Separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (candidates.Length <= 1) return;
+ _current = "";
+ foreach (var c in candidates) await AddTag(c);
+ }
+
+ private async Task AddTag(string tag)
+ {
+ var t = tag.Trim();
+ if (string.IsNullOrEmpty(t)) return;
+ if (MaxTags.HasValue && Tags.Count >= MaxTags.Value) return;
+ if (!AllowDuplicates && Tags.Contains(t, StringComparer.OrdinalIgnoreCase)) { _current = ""; return; }
+ var next = new List(Tags) { t };
+ Tags = next;
+ _current = "";
+ await TagsChanged.InvokeAsync(next);
+ }
+
+ private async Task RemoveTag(string tag)
+ {
+ var next = Tags.Where(t => !t.Equals(tag, StringComparison.Ordinal)).ToList();
+ Tags = next;
+ await TagsChanged.InvokeAsync(next);
+ }
+}
diff --git a/NET10/BlazorInteractiveServer/wwwroot/shellui.js b/NET10/BlazorInteractiveServer/wwwroot/shellui.js
index c424c56..e90b373 100644
--- a/NET10/BlazorInteractiveServer/wwwroot/shellui.js
+++ b/NET10/BlazorInteractiveServer/wwwroot/shellui.js
@@ -115,6 +115,31 @@ window.ShellUI = {
copyToClipboard: function (text) {
return navigator.clipboard.writeText(text);
+ },
+
+ _shortcutHandlers: new Map(),
+
+ registerShortcut: function (handle, key, ctrl, meta, shift, alt, dotNetRef) {
+ const listener = (e) => {
+ if (e.key.toLowerCase() !== key.toLowerCase()) return;
+ if (ctrl && !e.ctrlKey) return;
+ if (meta && !e.metaKey) return;
+ if (!ctrl && !meta && (e.ctrlKey || e.metaKey)) return;
+ if (shift !== e.shiftKey) return;
+ if (alt !== e.altKey) return;
+ e.preventDefault();
+ dotNetRef.invokeMethodAsync("OnShortcut");
+ };
+ window.addEventListener("keydown", listener);
+ this._shortcutHandlers.set(handle, listener);
+ },
+
+ unregisterShortcut: function (handle) {
+ const listener = this._shortcutHandlers.get(handle);
+ if (listener) {
+ window.removeEventListener("keydown", listener);
+ this._shortcutHandlers.delete(handle);
+ }
}
};
diff --git a/NET10/ShellUI.Preview/Pages/ComponentPreview.razor b/NET10/ShellUI.Preview/Pages/ComponentPreview.razor
index 7b92b51..6ee4409 100644
--- a/NET10/ShellUI.Preview/Pages/ComponentPreview.razor
+++ b/NET10/ShellUI.Preview/Pages/ComponentPreview.razor
@@ -48,6 +48,51 @@
case "combobox":
break;
+ case "data-picker":
+
+
+ @if (_pickedFramework is not null)
+ {
+
Picked: @_pickedFramework.Name (@_pickedFramework.Category)
+ }
+
+ break;
+ case "multi-select":
+
+
+
Selected: @_pickedFrameworks.Count
+
+ break;
+ case "tag-input":
+
+
+
Enter to add, Backspace to remove last, paste comma/newline-separated to bulk-add.
+
+ break;
+ case "command-palette":
+
+
Press Ctrl + K (or ⌘K ) to open.
+
+ @if (_lastCommand is not null)
+ {
+
Last selected: @_lastCommand
+ }
+
+ break;
case "select":
@@ -173,4 +218,32 @@
private List _comboboxOptions => new() { "Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro" };
private string _selectValue { get; set; } = "";
+
+ private record Framework(string Id, string Name, string Category);
+
+ private List _frameworks => new()
+ {
+ new("next", "Next.js", "React"),
+ new("sveltekit", "SvelteKit", "Svelte"),
+ new("nuxt", "Nuxt.js", "Vue"),
+ new("remix", "Remix", "React"),
+ new("astro", "Astro", "Multi"),
+ new("qwik", "Qwik", "Fresh"),
+ new("solidstart", "SolidStart", "Solid"),
+ new("blazor", "Blazor", ".NET"),
+ };
+ private Framework? _pickedFramework;
+ private List _pickedFrameworks = new();
+
+ private List _tags = new() { "blazor", "shellui" };
+
+ private List _paletteCommands => new()
+ {
+ new() { Title = "Go to Home", Description = "Navigate to home page", Shortcut = "Ctrl+H" },
+ new() { Title = "Toggle Theme", Description = "Switch light/dark mode", Shortcut = "Ctrl+T" },
+ new() { Title = "Search Components", Description = "Open component search" },
+ new() { Title = "Open Settings", Description = "Application preferences" },
+ new() { Title = "Sign Out", Description = "End your session" },
+ };
+ private string? _lastCommand;
}
diff --git a/NET10/ShellUI.Preview/Pages/Home.razor b/NET10/ShellUI.Preview/Pages/Home.razor
index 3e74683..ca30643 100644
--- a/NET10/ShellUI.Preview/Pages/Home.razor
+++ b/NET10/ShellUI.Preview/Pages/Home.razor
@@ -37,6 +37,10 @@
("card", "Card", " "),
("tabs", "Tabs", " "),
("combobox", "Combobox", " "),
+ ("data-picker", "DataPicker", " "),
+ ("multi-select", "MultiSelect", " "),
+ ("tag-input", "TagInput", " "),
+ ("command-palette", "CommandPalette", " "),
("select", "Select", " "),
("datepicker", "DatePicker", " "),
("skeleton", "Skeleton", " "),
diff --git a/ShellUI.Tests/TemplateSyncTests.cs b/ShellUI.Tests/TemplateSyncTests.cs
index 3bb9978..ad466f1 100644
--- a/ShellUI.Tests/TemplateSyncTests.cs
+++ b/ShellUI.Tests/TemplateSyncTests.cs
@@ -18,12 +18,20 @@ public class TemplateSyncTests
// Component name → reason. Empty by default — fix the drift instead of adding entries.
private static readonly Dictionary AllowedDrift = new()
{
+ // Live version imports the NuGet-package shellui.js module (Path A/B); template
+ // calls window.ShellUI directly (Path C, where shellui-js drops the monolith).
+ ["command-palette"] = "JS-interop path differs between package (module import) and CLI template (window.ShellUI global).",
+ ["input-otp"] = "JS-interop path differs between package (module import + fallback) and CLI template (window.ShellUI global)."
};
[Theory]
[InlineData("sidebar-trigger", "SidebarTrigger.razor")]
[InlineData("theme-toggle", "ThemeToggle.razor")]
[InlineData("input-otp", "InputOTP.razor")]
+ [InlineData("data-picker", "DataPicker.razor")]
+ [InlineData("multi-select", "MultiSelect.razor")]
+ [InlineData("tag-input", "TagInput.razor")]
+ [InlineData("command-palette", "CommandPalette.razor")]
public void TemplateCodeBlock_MatchesLiveLibrary(string templateName, string razorFileName)
{
if (AllowedDrift.ContainsKey(templateName)) return;
diff --git a/src/ShellUI.Components/Components/CommandPalette.razor b/src/ShellUI.Components/Components/CommandPalette.razor
new file mode 100644
index 0000000..9a6592a
--- /dev/null
+++ b/src/ShellUI.Components/Components/CommandPalette.razor
@@ -0,0 +1,81 @@
+@namespace ShellUI.Components
+@using Microsoft.JSInterop
+@using ShellUI.Components.Components
+@using ShellUI.Components.Models
+@implements IAsyncDisposable
+@inject IJSRuntime JS
+
+
+
+@code {
+ [Parameter] public List Commands { get; set; } = new();
+ [Parameter] public EventCallback CommandSelected { get; set; }
+ [Parameter] public string Placeholder { get; set; } = "Type a command or search...";
+
+ [Parameter] public string HotkeyChar { get; set; } = "k";
+ [Parameter] public bool UseCtrl { get; set; } = true;
+ [Parameter] public bool UseMeta { get; set; } = true;
+ [Parameter] public bool UseShift { get; set; }
+ [Parameter] public bool UseAlt { get; set; }
+
+ private bool _isOpen;
+ private IJSObjectReference? _module;
+ private DotNetObjectReference? _selfRef;
+ private readonly string _handle = Guid.NewGuid().ToString("N");
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (!firstRender) return;
+ _selfRef = DotNetObjectReference.Create(this);
+ try
+ {
+ _module = await JS.InvokeAsync("import", "./_content/ShellUI.Components/shellui.js");
+ await _module.InvokeVoidAsync("registerShortcut", _handle, HotkeyChar, UseCtrl, UseMeta, UseShift, UseAlt, _selfRef);
+ }
+ catch
+ {
+ try { await JS.InvokeVoidAsync("ShellUI.registerShortcut", _handle, HotkeyChar, UseCtrl, UseMeta, UseShift, UseAlt, _selfRef); } catch { }
+ }
+ }
+
+ [JSInvokable]
+ public Task OnShortcut()
+ {
+ _isOpen = !_isOpen;
+ StateHasChanged();
+ return Task.CompletedTask;
+ }
+
+ private Task OnIsOpenChanged(bool open)
+ {
+ _isOpen = open;
+ return Task.CompletedTask;
+ }
+
+ private async Task OnCommandSelected(CommandItem item)
+ {
+ await CommandSelected.InvokeAsync(item);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ try
+ {
+ if (_module is not null)
+ {
+ await _module.InvokeVoidAsync("unregisterShortcut", _handle);
+ await _module.DisposeAsync();
+ }
+ else
+ {
+ await JS.InvokeVoidAsync("ShellUI.unregisterShortcut", _handle);
+ }
+ }
+ catch { }
+ _selfRef?.Dispose();
+ }
+}
diff --git a/src/ShellUI.Components/Components/DataPicker.razor b/src/ShellUI.Components/Components/DataPicker.razor
new file mode 100644
index 0000000..b7c2dd0
--- /dev/null
+++ b/src/ShellUI.Components/Components/DataPicker.razor
@@ -0,0 +1,169 @@
+@namespace ShellUI.Components
+@typeparam TItem
+@typeparam TKey where TKey : notnull
+
+
+
+
+ @if (Value is not null && SelectedTemplate is not null)
+ {
+ @SelectedTemplate(Value)
+ }
+ else if (Value is not null)
+ {
+ @DisplayFor(Value)
+ }
+ else
+ {
+ @Placeholder
+ }
+
+
+
+
+
+
+ @if (IsOpen)
+ {
+
+
+
+ @{
+ var filtered = Filter().ToList();
+ }
+ @if (filtered.Count == 0)
+ {
+
@EmptyText
+ }
+ else
+ {
+ for (var i = 0; i < filtered.Count; i++)
+ {
+ var item = filtered[i];
+ var isSelected = Value is not null && KeyFor(item).Equals(KeyFor(Value));
+ var isHighlighted = i == _highlightedIndex;
+ var localIdx = i;
+
SelectItem(item)"
+ @onmouseenter="() => _highlightedIndex = localIdx"
+ class="@Shell.Cn("relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none", isHighlighted ? "bg-accent text-accent-foreground" : "", isSelected && !isHighlighted ? "bg-accent/50" : "")">
+ @if (OptionTemplate is not null)
+ {
+ @OptionTemplate(item)
+ }
+ else
+ {
+ @DisplayFor(item)
+ }
+ @if (isSelected)
+ {
+
+
+
+
+
+ }
+
+ }
+ }
+
+
+ }
+
+
+@if (IsOpen)
+{
+
+}
+
+@code {
+ [Parameter, EditorRequired] public IEnumerable Items { get; set; } = Array.Empty();
+ [Parameter] public TItem? Value { get; set; }
+ [Parameter] public EventCallback ValueChanged { get; set; }
+ [Parameter, EditorRequired] public Func KeySelector { get; set; } = default!;
+ [Parameter] public Func? DisplaySelector { get; set; }
+ [Parameter] public Func? SearchPredicate { get; set; }
+ [Parameter] public RenderFragment? SelectedTemplate { get; set; }
+ [Parameter] public RenderFragment? OptionTemplate { get; set; }
+ [Parameter] public string Placeholder { get; set; } = "Select...";
+ [Parameter] public string SearchPlaceholder { get; set; } = "Search...";
+ [Parameter] public string EmptyText { get; set; } = "No results found.";
+ [Parameter] public bool Disabled { get; set; }
+ [Parameter] public string? Class { get; set; }
+ [Parameter(CaptureUnmatchedValues = true)]
+ public Dictionary? AdditionalAttributes { get; set; }
+
+ private bool IsOpen { get; set; }
+ private string _searchQuery = "";
+ private int _highlightedIndex;
+ private ElementReference _searchInput;
+ private bool _shouldFocus;
+
+ private string DisplayFor(TItem item) => DisplaySelector?.Invoke(item) ?? item?.ToString() ?? "";
+ private TKey KeyFor(TItem item) => KeySelector(item);
+
+ private IEnumerable Filter()
+ {
+ if (string.IsNullOrWhiteSpace(_searchQuery)) return Items;
+ var q = _searchQuery;
+ if (SearchPredicate is not null) return Items.Where(i => SearchPredicate(i, q));
+ return Items.Where(i => DisplayFor(i).Contains(q, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private void Toggle()
+ {
+ if (Disabled) return;
+ IsOpen = !IsOpen;
+ if (IsOpen)
+ {
+ _searchQuery = "";
+ _highlightedIndex = 0;
+ _shouldFocus = true;
+ }
+ }
+
+ private void Close() => IsOpen = false;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (_shouldFocus)
+ {
+ _shouldFocus = false;
+ try { await _searchInput.FocusAsync(); } catch { }
+ }
+ }
+
+ private async Task SelectItem(TItem item)
+ {
+ Value = item;
+ IsOpen = false;
+ await ValueChanged.InvokeAsync(item);
+ }
+
+ private async Task OnKeyDown(KeyboardEventArgs e)
+ {
+ var filtered = Filter().ToList();
+ if (e.Key == "Escape") { Close(); return; }
+ if (e.Key == "ArrowDown") { _highlightedIndex = Math.Min(_highlightedIndex + 1, filtered.Count - 1); return; }
+ if (e.Key == "ArrowUp") { _highlightedIndex = Math.Max(_highlightedIndex - 1, 0); return; }
+ if (e.Key == "Enter" && filtered.Count > 0)
+ {
+ var idx = Math.Clamp(_highlightedIndex, 0, filtered.Count - 1);
+ await SelectItem(filtered[idx]);
+ }
+ }
+}
diff --git a/src/ShellUI.Components/Components/InputOTP.razor b/src/ShellUI.Components/Components/InputOTP.razor
index c304de1..778082a 100644
--- a/src/ShellUI.Components/Components/InputOTP.razor
+++ b/src/ShellUI.Components/Components/InputOTP.razor
@@ -116,16 +116,18 @@
private async Task FocusInput(int index)
{
- if (index >= 0 && index < Length)
+ if (index < 0 || index >= Length) return;
+ var elementId = $"otp-input-{_id}-{index}";
+ try
{
- try
- {
- await JS.InvokeVoidAsync("ShellUI.focusElement", $"otp-input-{_id}-{index}");
- }
- catch
- {
- // Ignore JS interop errors
- }
+ // Path A/B (NuGet package): import module to ensure window.ShellUI is bootstrapped.
+ var module = await JS.InvokeAsync("import", "./_content/ShellUI.Components/shellui.js");
+ await module.InvokeVoidAsync("focusElement", elementId);
+ }
+ catch
+ {
+ // Path C (CLI): shellui.js was loaded via