diff --git a/NET10/BlazorInteractiveServer/Components/Demo/NavigationDemo.razor b/NET10/BlazorInteractiveServer/Components/Demo/NavigationDemo.razor index c6ffa76..ff650c8 100644 --- a/NET10/BlazorInteractiveServer/Components/Demo/NavigationDemo.razor +++ b/NET10/BlazorInteractiveServer/Components/Demo/NavigationDemo.razor @@ -31,13 +31,11 @@

Sidebar

+

Inline mode — Inline="true" keeps the sidebar inside the demo card. For full-viewport usage see /dashboard.

-
- -
+ + -
+

Main Content Area

-

- Toggle the sidebar to see the smooth slide-in animation! -

+

Sidebar respects the bounded container thanks to Inline="true".

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

+
+ + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
ItemsIEnumerable<TItem>Source list (required)
ValueTItem?nullCurrently selected item
ValueChangedEventCallback<TItem?>Fired when selection changes
KeySelectorFunc<TItem, TKey>Extract stable identity (required)
DisplaySelectorFunc<TItem, string>?ToStringDefault row display
SearchPredicateFunc<TItem, string, bool>?DisplaySelector ContainsCustom filter
SelectedTemplateRenderFragment<TItem>?Custom render for selected value in the trigger
OptionTemplateRenderFragment<TItem>?Custom render for each option row
Placeholderstring"Select..."Trigger placeholder
DisabledboolfalseDisables 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

+
+ + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
ItemsIEnumerable<TItem>Source list (required)
ValuesList<TItem>emptyCurrently selected items
ValuesChangedEventCallback<List<TItem>>Fired when selection changes
KeySelectorFunc<TItem, TKey>Extract stable identity (required)
ChipTemplateRenderFragment<TItem>?Custom render for chips in the trigger
OptionTemplateRenderFragment<TItem>?Custom render for each option row
DisabledboolfalseDisables 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

+
+ + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
TagsList<string>emptyCurrent tags
TagsChangedEventCallback<List<string>>Fired when tags change
Placeholderstring"Add tag..."Placeholder while empty
MaxTagsint?nullCap the number of tags
AllowDuplicatesboolfalseAllow identical tags (case-insensitive by default)
Separatorsstring[]",", ";", "\t", "\n"Split chars for paste-to-bulk-add
DisabledboolfalseDisables 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 @@

Overlay

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

+
+ + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
CommandsList<CommandItem>emptyCommands available in the palette
CommandSelectedEventCallback<CommandItem>Fired when the user picks a command
HotkeyCharstring"k"Hotkey character
UseCtrlbooltrueRequire Ctrl
UseMetabooltrueRequire ⌘ (Meta) — either Ctrl or Meta triggers
UseShift / UseAltboolfalseRequire 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 (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; + + } + } +
+
+ } +
+ +@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 (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; + + } + } +
+
+ } +
+ +@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 + + + + + + + } + +
+ +@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":
+
+
+ @{ + 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; + + } + } +
+
+ } +
+ +@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