From ed8e9b716226f7312d9e7aafa01c80a38349beff Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 00:04:03 +0200 Subject: [PATCH 01/13] test: add additional template sync test cases for new components Enhanced the `TemplateSyncTests` by adding new inline data for the `data-picker`, `multi-select`, `tag-input`, and `command-palette` components. This improves test coverage for template synchronization, ensuring that the new components are correctly matched with their corresponding Razor files. --- ShellUI.Tests/TemplateSyncTests.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ShellUI.Tests/TemplateSyncTests.cs b/ShellUI.Tests/TemplateSyncTests.cs index 3bb9978..268aee6 100644 --- a/ShellUI.Tests/TemplateSyncTests.cs +++ b/ShellUI.Tests/TemplateSyncTests.cs @@ -24,6 +24,10 @@ public class TemplateSyncTests [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; From f48908b9dc0596bddc3bd13eb2f372006a7c25c0 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 00:04:35 +0200 Subject: [PATCH 02/13] feat: add new safelist entries for improved styling options Enhanced the `ShellUI.Components.targets` file by adding new safelist entries for `flex-wrap`, `focus:ring-1`, `hover:text-destructive`, `left-2`, and `min-w-24`. These additions expand the available utility classes, improving styling flexibility and responsiveness in the ShellUI components. --- src/ShellUI.Components/build/ShellUI.Components.targets | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/ShellUI.Components/build/ShellUI.Components.targets b/src/ShellUI.Components/build/ShellUI.Components.targets index 6ecc333..081efce 100644 --- a/src/ShellUI.Components/build/ShellUI.Components.targets +++ b/src/ShellUI.Components/build/ShellUI.Components.targets @@ -93,6 +93,7 @@ + @@ -107,6 +108,7 @@ + @@ -169,6 +171,7 @@ + @@ -188,6 +191,7 @@ + @@ -203,6 +207,7 @@ + From b448ddcf1be46dd174227ae8a83cb1b09f6a8c5a Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 00:04:50 +0200 Subject: [PATCH 03/13] feat: add new components for enhanced user interaction Introduced four new components: `CommandPalette`, `DataPicker`, `MultiSelect`, and `TagInput`. These components enhance user interaction by providing command selection, date picking, multi-selection capabilities, and tag input functionality, respectively. Each component includes customizable parameters and event callbacks to improve usability and integration within the ShellUI framework. --- .../Components/CommandPalette.razor | 71 +++++++ .../Components/DataPicker.razor | 169 +++++++++++++++ .../Components/MultiSelect.razor | 198 ++++++++++++++++++ .../Components/TagInput.razor | 95 +++++++++ 4 files changed, 533 insertions(+) create mode 100644 src/ShellUI.Components/Components/CommandPalette.razor create mode 100644 src/ShellUI.Components/Components/DataPicker.razor create mode 100644 src/ShellUI.Components/Components/MultiSelect.razor create mode 100644 src/ShellUI.Components/Components/TagInput.razor diff --git a/src/ShellUI.Components/Components/CommandPalette.razor b/src/ShellUI.Components/Components/CommandPalette.razor new file mode 100644 index 0000000..29d8368 --- /dev/null +++ b/src/ShellUI.Components/Components/CommandPalette.razor @@ -0,0 +1,71 @@ +@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..."; + + /// Hotkey character. Default "k" — combined with Ctrl/Cmd = Cmd+K / Ctrl+K. + [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); + _module = await JS.InvokeAsync("import", "./_content/ShellUI.Components/shellui.js"); + await _module.InvokeVoidAsync("registerShortcut", _handle, HotkeyChar, UseCtrl, UseMeta, UseShift, UseAlt, _selfRef); + } + + [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(); + } + } + 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 (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/src/ShellUI.Components/Components/MultiSelect.razor b/src/ShellUI.Components/Components/MultiSelect.razor new file mode 100644 index 0000000..e90bb93 --- /dev/null +++ b/src/ShellUI.Components/Components/MultiSelect.razor @@ -0,0 +1,198 @@ +@namespace ShellUI.Components +@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/src/ShellUI.Components/Components/TagInput.razor b/src/ShellUI.Components/Components/TagInput.razor new file mode 100644 index 0000000..f6e8949 --- /dev/null +++ b/src/ShellUI.Components/Components/TagInput.razor @@ -0,0 +1,95 @@ +@namespace ShellUI.Components + +
+ @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); + } +} From 3cd9fcff8f162403ed4409efa7ecdaff5f529c05 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 00:05:22 +0200 Subject: [PATCH 04/13] feat: add new form components for enhanced user input Introduced four new components: `CommandPalette`, `DataPicker`, `MultiSelect`, and `TagInput`. These components provide improved user interaction through command selection, date picking, multi-value selection, and tag input functionalities. Each component is designed with customizable parameters and event callbacks to enhance usability within the ShellUI framework. --- .../Templates/CommandPaletteTemplate.cs | 85 +++++++ .../Templates/DataPickerTemplate.cs | 186 +++++++++++++++ .../Templates/MultiSelectTemplate.cs | 215 ++++++++++++++++++ .../Templates/TagInputTemplate.cs | 112 +++++++++ 4 files changed, 598 insertions(+) create mode 100644 src/ShellUI.Templates/Templates/CommandPaletteTemplate.cs create mode 100644 src/ShellUI.Templates/Templates/DataPickerTemplate.cs create mode 100644 src/ShellUI.Templates/Templates/MultiSelectTemplate.cs create mode 100644 src/ShellUI.Templates/Templates/TagInputTemplate.cs diff --git a/src/ShellUI.Templates/Templates/CommandPaletteTemplate.cs b/src/ShellUI.Templates/Templates/CommandPaletteTemplate.cs new file mode 100644 index 0000000..7753e82 --- /dev/null +++ b/src/ShellUI.Templates/Templates/CommandPaletteTemplate.cs @@ -0,0 +1,85 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class CommandPaletteTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "command-palette", + DisplayName = "CommandPalette", + Description = "Cmd+K palette wrapper — binds a global hotkey to open a Command", + Category = ComponentCategory.Overlay, + FilePath = "CommandPalette.razor", + Dependencies = new List { "command" } + }; + + public static string Content => @"@namespace YourProjectNamespace.Components.UI +@using Microsoft.JSInterop +@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); + _module = await JS.InvokeAsync(""import"", ""./_content/ShellUI.Components/shellui.js""); + await _module.InvokeVoidAsync(""registerShortcut"", _handle, HotkeyChar, UseCtrl, UseMeta, UseShift, UseAlt, _selfRef); + } + + [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(); + } + } + catch { } + _selfRef?.Dispose(); + } +} +"; +} diff --git a/src/ShellUI.Templates/Templates/DataPickerTemplate.cs b/src/ShellUI.Templates/Templates/DataPickerTemplate.cs new file mode 100644 index 0000000..baf69fb --- /dev/null +++ b/src/ShellUI.Templates/Templates/DataPickerTemplate.cs @@ -0,0 +1,186 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class DataPickerTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "data-picker", + DisplayName = "DataPicker", + Description = "Generic typed picker with search, keyboard nav, and templated rendering", + Category = ComponentCategory.Form, + FilePath = "DataPicker.razor" + }; + + public static string Content => @"@namespace YourProjectNamespace.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/src/ShellUI.Templates/Templates/MultiSelectTemplate.cs b/src/ShellUI.Templates/Templates/MultiSelectTemplate.cs new file mode 100644 index 0000000..468ee8d --- /dev/null +++ b/src/ShellUI.Templates/Templates/MultiSelectTemplate.cs @@ -0,0 +1,215 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class MultiSelectTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "multi-select", + DisplayName = "MultiSelect", + Description = "Generic multi-value picker with chip display and search", + Category = ComponentCategory.Form, + FilePath = "MultiSelect.razor" + }; + + public static string Content => @"@namespace YourProjectNamespace.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/src/ShellUI.Templates/Templates/TagInputTemplate.cs b/src/ShellUI.Templates/Templates/TagInputTemplate.cs new file mode 100644 index 0000000..545281f --- /dev/null +++ b/src/ShellUI.Templates/Templates/TagInputTemplate.cs @@ -0,0 +1,112 @@ +using ShellUI.Core.Models; + +namespace ShellUI.Templates.Templates; + +public static class TagInputTemplate +{ + public static ComponentMetadata Metadata => new() + { + Name = "tag-input", + DisplayName = "TagInput", + Description = "Free-form tag input with chip display and paste splitting", + Category = ComponentCategory.Form, + FilePath = "TagInput.razor" + }; + + public static string Content => @"@namespace YourProjectNamespace.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); + } +} +"; +} From 256912fa77afda37823adc8f67cdd32cf35d19f0 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 00:05:49 +0200 Subject: [PATCH 05/13] feat: register new components in ComponentRegistry for enhanced functionality Added entries for `command-palette`, `data-picker`, `multi-select`, and `tag-input` to the `ComponentRegistry`. This update integrates the new components into the ShellUI framework, allowing for improved user interaction and functionality across the application. --- src/ShellUI.Templates/ComponentRegistry.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ShellUI.Templates/ComponentRegistry.cs b/src/ShellUI.Templates/ComponentRegistry.cs index a1eb868..4ab28b9 100644 --- a/src/ShellUI.Templates/ComponentRegistry.cs +++ b/src/ShellUI.Templates/ComponentRegistry.cs @@ -141,6 +141,10 @@ public static class ComponentRegistry { "hover-card-trigger", HoverCardTriggerTemplate.Metadata }, { "hover-card-content", HoverCardContentTemplate.Metadata }, { "command", CommandTemplate.Metadata }, + { "command-palette", CommandPaletteTemplate.Metadata }, + { "data-picker", DataPickerTemplate.Metadata }, + { "multi-select", MultiSelectTemplate.Metadata }, + { "tag-input", TagInputTemplate.Metadata }, { "context-menu", ContextMenuTemplate.Metadata }, { "context-menu-trigger", ContextMenuTriggerTemplate.Metadata }, { "context-menu-content", ContextMenuContentTemplate.Metadata }, @@ -313,6 +317,10 @@ public static class ComponentRegistry "hover-card-trigger" => HoverCardTriggerTemplate.Content, "hover-card-content" => HoverCardContentTemplate.Content, "command" => CommandTemplate.Content, + "command-palette" => CommandPaletteTemplate.Content, + "data-picker" => DataPickerTemplate.Content, + "multi-select" => MultiSelectTemplate.Content, + "tag-input" => TagInputTemplate.Content, "context-menu" => ContextMenuTemplate.Content, "context-menu-trigger" => ContextMenuTriggerTemplate.Content, "context-menu-content" => ContextMenuContentTemplate.Content, From 1aa030422c66f13cda0bd9f615339fbe0df8259b Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 00:06:23 +0200 Subject: [PATCH 06/13] feat: enhance utility classes and add keyboard shortcut registration Updated `shellui-classes.txt` to include new utility classes: `flex-wrap`, `focus:ring-1`, `hover:text-destructive`, `left-2`, and `min-w-24`, improving styling options. Additionally, implemented keyboard shortcut registration and unregistration functions in `shellui.js`, allowing for customizable keyboard interactions within the ShellUI framework. --- .../wwwroot/shellui-classes.txt | 5 ++++ src/ShellUI.Components/wwwroot/shellui.js | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/ShellUI.Components/wwwroot/shellui-classes.txt b/src/ShellUI.Components/wwwroot/shellui-classes.txt index 13154f7..77b7dc4 100644 --- a/src/ShellUI.Components/wwwroot/shellui-classes.txt +++ b/src/ShellUI.Components/wwwroot/shellui-classes.txt @@ -90,6 +90,7 @@ flex-col flex-col-reverse flex-row flex-shrink-0 +flex-wrap focus-visible:[&::-moz-range-thumb]:ring-2 focus-visible:[&::-moz-range-thumb]:ring-offset-2 focus-visible:[&::-moz-range-thumb]:ring-ring @@ -104,6 +105,7 @@ focus-visible:ring-offset-2 focus-visible:ring-ring focus:bg-accent focus:outline-none +focus:ring-1 focus:ring-2 focus:ring-offset-2 focus:ring-ring @@ -166,6 +168,7 @@ hover:bg-sidebar-accent hover:bg-yellow-600 hover:opacity-100 hover:text-accent-foreground +hover:text-destructive hover:text-foreground hover:text-muted-foreground hover:text-sidebar-accent-foreground @@ -185,6 +188,7 @@ leading-none leading-tight left-0 left-1/2 +left-2 lg:space-x-8 list-none material-symbols-outlined @@ -200,6 +204,7 @@ min-h-0 min-h-8 min-h-[80px] min-w-0 +min-w-24 min-w-8 min-w-[640px] min-w-[8rem] diff --git a/src/ShellUI.Components/wwwroot/shellui.js b/src/ShellUI.Components/wwwroot/shellui.js index d9f1d6a..ed8a803 100644 --- a/src/ShellUI.Components/wwwroot/shellui.js +++ b/src/ShellUI.Components/wwwroot/shellui.js @@ -5,3 +5,29 @@ export function copyToClipboard(text) { return navigator.clipboard.writeText(text); } + +// Global keyboard-shortcut registry keyed by handle so Blazor can dispose cleanly. +const shortcutHandlers = new Map(); + +export function registerShortcut(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); + shortcutHandlers.set(handle, listener); +} + +export function unregisterShortcut(handle) { + const listener = shortcutHandlers.get(handle); + if (listener) { + window.removeEventListener("keydown", listener); + shortcutHandlers.delete(handle); + } +} From 2f33d3a2f31761d203f736e32fc02d621821102e Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 01:28:21 +0200 Subject: [PATCH 07/13] feat: add new components for enhanced user interaction Introduced `DataPicker`, `MultiSelect`, `TagInput`, and `CommandPalette` components to the ShellUI framework. These components enhance user interaction by providing functionalities for date picking, multi-value selection, free-form tag entry, and command execution via keyboard shortcuts. Each component includes customizable parameters and event callbacks to improve usability and integration within the application. --- .../Pages/Components/Form/DataPickerDoc.razor | 122 +++++++++++ .../Components/Form/MultiSelectDoc.razor | 95 +++++++++ .../Pages/Components/Form/TagInputDoc.razor | 76 +++++++ .../Overlay/CommandPaletteDoc.razor | 73 +++++++ .../Components/UI/AppSidebar.razor | 4 + .../Components/UI/CommandPalette.razor | 63 ++++++ .../Components/UI/DataPicker.razor | 169 +++++++++++++++ .../Components/UI/MultiSelect.razor | 198 ++++++++++++++++++ .../Components/UI/Sidebar.razor | 13 +- .../Components/UI/TagInput.razor | 95 +++++++++ 10 files changed, 905 insertions(+), 3 deletions(-) create mode 100644 NET10/BlazorInteractiveServer/Components/Pages/Components/Form/DataPickerDoc.razor create mode 100644 NET10/BlazorInteractiveServer/Components/Pages/Components/Form/MultiSelectDoc.razor create mode 100644 NET10/BlazorInteractiveServer/Components/Pages/Components/Form/TagInputDoc.razor create mode 100644 NET10/BlazorInteractiveServer/Components/Pages/Components/Overlay/CommandPaletteDoc.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/DataPicker.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/MultiSelect.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/TagInput.razor 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/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/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); + } +} From 5815d09fc7664f613ceab6c0e8fe6b5b67f5f2cd Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 01:28:53 +0200 Subject: [PATCH 08/13] feat: update Index.razor to include new component links Added links for `DataPicker`, `MultiSelect`, `TagInput`, and `CommandPalette` components in the Index.razor file. This enhances navigation and accessibility to the newly introduced components within the ShellUI framework. --- .../Components/Pages/Components/Index.razor | 4 ++++ 1 file changed, 4 insertions(+) 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

From 913888c5146d9f00df66cc75c7c21bf523006d12 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 01:29:18 +0200 Subject: [PATCH 09/13] feat: update NavigationDemo and layout references for improved sidebar functionality Enhanced the NavigationDemo component by adding an inline mode description for the sidebar and adjusting the layout references in Blocks.razor and Home.razor to use BlocksLayout and MainLayout respectively. This improves user experience and consistency across the application. --- .../Components/Demo/NavigationDemo.razor | 16 ++++++---------- .../Components/Pages/Blocks.razor | 2 +- .../Components/Pages/Home.razor | 3 ++- 3 files changed, 9 insertions(+), 12 deletions(-) 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/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 From 0ac65293e6e159e0c751ad11d89d56c28c3ef969 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 4 Jul 2026 01:29:39 +0200 Subject: [PATCH 10/13] feat: add new components to ComponentPreview for enhanced user interaction Introduced `DataPicker`, `MultiSelect`, `TagInput`, and `CommandPalette` components in the ComponentPreview.razor file, allowing users to interactively select frameworks, manage multiple selections, input tags, and execute commands via keyboard shortcuts. Updated Home.razor to include corresponding entries for these components, improving navigation and accessibility within the ShellUI framework. --- .../Pages/ComponentPreview.razor | 73 +++++++++++++++++++ NET10/ShellUI.Preview/Pages/Home.razor | 4 + 2 files changed, 77 insertions(+) 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":