From 321cd6506828793d74adb9901850f8c9b68a06a7 Mon Sep 17 00:00:00 2001 From: Shewatipa Tseisi Date: Sun, 27 Sep 2026 14:29:27 +0200 Subject: [PATCH 1/7] feat: introduce new components and enhance existing demos Added new components including StatCard, Chat, ButtonGroup, ToggleGroup, InputGroup, NumberInput, TreeView, Timeline, QrCode, ImageViewer, AspectRatio, and Kbd. Updated the ComplexComponentsDemo and created a NewComponentsDemo to showcase these additions. Enhanced the App layout with a new CSS link for charts and improved the command palette functionality. Updated Home and Components pages to reflect the new components and their usage. --- .../Components/App.razor | 1 + .../Demo/ComplexComponentsDemo.razor | 79 +++-- .../Components/Demo/NewComponentsDemo.razor | 168 ++++++++++ .../Components/Layout/MainLayout.razor | 44 ++- .../Components/Pages/Components/Index.razor | 5 + .../Pages/Components/NewComponents.razor | 14 + .../Components/Pages/Home.razor | 4 + .../Components/UI/AspectRatio.razor | 15 + .../Components/UI/ButtonGroup.razor | 18 ++ .../Components/UI/Chart.razor | 2 - .../Components/UI/Chat.razor | 30 ++ .../Components/UI/ChatInput.razor | 94 ++++++ .../Components/UI/ChatMessage.razor | 65 ++++ .../Components/UI/Command.razor | 254 ++++++++------- .../Components/UI/CommandPalette.razor | 18 +- .../Components/UI/DialogContent.razor | 17 +- .../Components/UI/DrawerContent.razor | 17 +- .../Components/UI/ImageViewer.razor | 81 +++++ .../Components/UI/InputGroup.razor | 38 +++ .../Components/UI/Kbd.razor | 10 + .../Components/UI/Loading.razor | 293 +++++++++++++----- .../{ => UI}/Models/CommandModels.cs | 2 + .../Components/UI/NumberInput.razor | 67 ++++ .../Components/UI/QrCode.razor | 72 +++++ .../Components/UI/RadarChart.razor | 3 +- .../Components/UI/SheetContent.razor | 17 +- .../Components/UI/SidebarProvider.razor | 40 +-- .../Components/UI/StatCard.razor | 43 +++ .../Components/UI/Timeline.razor | 12 + .../Components/UI/TimelineItem.razor | 39 +++ .../Components/UI/ToggleGroup.razor | 45 +++ .../Components/UI/ToggleGroupItem.razor | 37 +++ .../Components/UI/TreeView.razor | 28 ++ .../Components/UI/TreeViewItem.razor | 68 ++++ .../Components/UI/Variants/ChartVariants.cs | 4 +- .../Components/_Imports.razor | 2 + .../Components/AspectRatio.razor | 15 + .../Components/ButtonGroup.razor | 18 ++ src/ShellUI.Components/Components/Chat.razor | 30 ++ .../Components/ChatInput.razor | 94 ++++++ .../Components/ChatMessage.razor | 65 ++++ .../Components/Command.razor | 254 ++++++++------- .../Components/CommandPalette.razor | 18 +- .../Components/DialogContent.razor | 17 +- .../Components/DrawerContent.razor | 17 +- .../Components/ImageViewer.razor | 81 +++++ .../Components/InputGroup.razor | 38 +++ src/ShellUI.Components/Components/Kbd.razor | 10 + .../Components/Loading.razor | 292 ++++++++++++----- .../Components/NumberInput.razor | 67 ++++ .../Components/QrCode.razor | 72 +++++ .../Components/RadarChart.razor | 3 +- .../Components/SheetContent.razor | 17 +- .../Components/StatCard.razor | 43 +++ .../Components/Timeline.razor | 12 + .../Components/TimelineItem.razor | 39 +++ .../Components/ToggleGroup.razor | 45 +++ .../Components/ToggleGroupItem.razor | 37 +++ .../Components/TreeView.razor | 28 ++ .../Components/TreeViewItem.razor | 68 ++++ .../Models/CommandModels.cs | 1 + src/ShellUI.Components/README.md | 2 +- .../ShellUI.Components.csproj | 1 + .../build/ShellUI.Components.targets | 60 +++- .../wwwroot/shellui-classes.txt | 60 +++- src/ShellUI.Components/wwwroot/shellui.js | 5 +- 66 files changed, 2758 insertions(+), 497 deletions(-) create mode 100644 NET10/BlazorInteractiveServer/Components/Demo/NewComponentsDemo.razor create mode 100644 NET10/BlazorInteractiveServer/Components/Pages/Components/NewComponents.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/AspectRatio.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/ButtonGroup.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/Chat.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/ChatInput.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/ChatMessage.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/ImageViewer.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/InputGroup.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/Kbd.razor rename NET10/BlazorInteractiveServer/Components/{ => UI}/Models/CommandModels.cs (88%) create mode 100644 NET10/BlazorInteractiveServer/Components/UI/NumberInput.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/QrCode.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/StatCard.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/Timeline.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/TimelineItem.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/ToggleGroup.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/ToggleGroupItem.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/TreeView.razor create mode 100644 NET10/BlazorInteractiveServer/Components/UI/TreeViewItem.razor create mode 100644 src/ShellUI.Components/Components/AspectRatio.razor create mode 100644 src/ShellUI.Components/Components/ButtonGroup.razor create mode 100644 src/ShellUI.Components/Components/Chat.razor create mode 100644 src/ShellUI.Components/Components/ChatInput.razor create mode 100644 src/ShellUI.Components/Components/ChatMessage.razor create mode 100644 src/ShellUI.Components/Components/ImageViewer.razor create mode 100644 src/ShellUI.Components/Components/InputGroup.razor create mode 100644 src/ShellUI.Components/Components/Kbd.razor create mode 100644 src/ShellUI.Components/Components/NumberInput.razor create mode 100644 src/ShellUI.Components/Components/QrCode.razor create mode 100644 src/ShellUI.Components/Components/StatCard.razor create mode 100644 src/ShellUI.Components/Components/Timeline.razor create mode 100644 src/ShellUI.Components/Components/TimelineItem.razor create mode 100644 src/ShellUI.Components/Components/ToggleGroup.razor create mode 100644 src/ShellUI.Components/Components/ToggleGroupItem.razor create mode 100644 src/ShellUI.Components/Components/TreeView.razor create mode 100644 src/ShellUI.Components/Components/TreeViewItem.razor diff --git a/NET10/BlazorInteractiveServer/Components/App.razor b/NET10/BlazorInteractiveServer/Components/App.razor index b034cfb..7246aae 100644 --- a/NET10/BlazorInteractiveServer/Components/App.razor +++ b/NET10/BlazorInteractiveServer/Components/App.razor @@ -60,6 +60,7 @@ #blazor-error-ui a { color: inherit; text-decoration: underline; } #blazor-error-ui .dismiss { cursor: pointer; margin-left: 1rem; } + diff --git a/NET10/BlazorInteractiveServer/Components/Demo/ComplexComponentsDemo.razor b/NET10/BlazorInteractiveServer/Components/Demo/ComplexComponentsDemo.razor index 523bc50..5287ffb 100644 --- a/NET10/BlazorInteractiveServer/Components/Demo/ComplexComponentsDemo.razor +++ b/NET10/BlazorInteractiveServer/Components/Demo/ComplexComponentsDemo.razor @@ -41,47 +41,24 @@

Loading Components

-
-
- -

Spinner SM

-
-
- -

Spinner

-
-
- -

Dots

-
-
- -

Ring

-
-
- -

Pulse

-
-
- -

Bars

-
-
- -

Bars Vertical

-
-
- -

Bars Pulse

-
-
- -

Grid

-
-
- -

Orbit

-
+
+ @foreach (var (variant, label) in _loadingVariants) + { +
+
+ +
+

@label

+
+ } +
+
+ + + + + +
@@ -189,7 +166,12 @@ Title="No files found" Description="Get started by uploading your first file."> - + +
@@ -374,6 +356,15 @@ @code { + private readonly (string Variant, string Label)[] _loadingVariants = + { + ("logo", "ShellUI Logo"), ("spinner", "Spinner"), ("ring", "Ring"), ("dual-ring", "Dual Ring"), ("activity", "Activity"), ("chase", "Chase"), + ("orbit", "Orbit"), ("ripple", "Ripple"), ("dots", "Dots"), ("typing", "Typing"), ("wave", "Wave"), + ("bars", "Bars"), ("bars-vertical", "Bars Vertical"), ("bars-pulse", "Bars Pulse"), ("grid", "Grid"), ("snake", "Snake"), + ("rows", "Rows"), ("columns", "Columns"), ("sequence", "Sequence"), ("twinkle", "Twinkle"), ("pulse", "Pulse"), + ("progress", "Progress"), ("shimmer", "Shimmer") + }; + private bool _showCommand = false; private bool _collapsibleOpen = false; private bool _stepperSubmitted = false; @@ -437,6 +428,10 @@ await ShowCommandChanged.InvokeAsync(value); } + private string? _emptyStateFile; + + private void OnEmptyStateFileSelected(InputFileChangeEventArgs e) => _emptyStateFile = e.File.Name; + private async Task HandleCommandSelected(CommandItem command) { await OnCommandSelected.InvokeAsync(command); diff --git a/NET10/BlazorInteractiveServer/Components/Demo/NewComponentsDemo.razor b/NET10/BlazorInteractiveServer/Components/Demo/NewComponentsDemo.razor new file mode 100644 index 0000000..dfc739c --- /dev/null +++ b/NET10/BlazorInteractiveServer/Components/Demo/NewComponentsDemo.razor @@ -0,0 +1,168 @@ +
+

Stat Card

+

KPI tiles with a trend badge.

+
+ + + +
+
+ +
+

Chat

+

Enter sends, Shift+Enter adds a new line. Replies are simulated.

+ +
+
+ Assistant + @_messages.Count messages +
+
+ + @foreach (var message in _messages) + { + + } + @if (_thinking) + { + + } + +
+ +
+
+
+ +
+

Toggle Group & Button Group

+
+
+ + Left + Center + Right + + Single: @(_align ?? "none") +
+
+ + B + I + U + + Multiple: @(_formats.Count == 0 ? "none" : string.Join(", ", _formats)) +
+
+ + + + + + + + + + +
+
+
+ +
+

Input Group & Number Input

+
+
+ + https:// + .com + + + $ + USD + +
+
+ +

Quantity: @_quantity (0 to 10)

+
+
+
+ +
+

Tree View & Timeline

+
+
+ + + + + + + + + + +

Selected: @(_selectedFile ?? "none")

+
+ + Published to NuGet and GitHub. + Donut, radar and radial charts added. + + +
+
+ +
+

QR Code, Image Viewer & Aspect Ratio

+
+
+ +

Scan to open the repo

+
+
+ +

Click to open, then zoom

+
+
+ +
16 : 9
+
+

Keeps its ratio at any width

+
+
+
+ +
+

Kbd

+

+ Open the command palette with Ctrl K, or toggle the sidebar with Ctrl B. +

+
+ +@code { + private readonly List<(string Role, string Text)> _messages = new() + { + ("assistant", "Hi! I'm a ShellUI chat demo. Ask me anything."), + }; + + private string? _draft; + private bool _thinking; + private string? _align = "center"; + private List _formats = new() { "bold" }; + private string? _website; + private string? _price; + private decimal? _quantity = 1; + private string? _selectedFile = "Chat.razor"; + + private const string SampleImage = "data:image/svg+xml;utf8,"; + + private async Task SendAsync(string text) + { + _messages.Add(("user", text)); + _thinking = true; + StateHasChanged(); + await Task.Delay(800); + _messages.Add(("assistant", $"You said:\n{text}")); + _thinking = false; + } +} diff --git a/NET10/BlazorInteractiveServer/Components/Layout/MainLayout.razor b/NET10/BlazorInteractiveServer/Components/Layout/MainLayout.razor index 2c1a521..f92d8f8 100644 --- a/NET10/BlazorInteractiveServer/Components/Layout/MainLayout.razor +++ b/NET10/BlazorInteractiveServer/Components/Layout/MainLayout.razor @@ -4,7 +4,7 @@ @using BlazorInteractiveServer.Components.Models @using BlazorInteractiveServer.Components.UI -
+
@@ -136,10 +136,9 @@ - + @code { private bool _isMobileMenuOpen = false; @@ -153,45 +152,52 @@ { new CommandItem { - Title = "Home", + Title = "Home", + Group = "Navigation", Description = "Go to homepage", Shortcut = "Ctrl+H", Action = async () => { NavigationManager.NavigateTo("/"); await Task.CompletedTask; } }, new CommandItem { - Title = "Dashboard", + Title = "Dashboard", + Group = "Navigation", Description = "View dashboard", Shortcut = "Ctrl+D", Action = async () => { NavigationManager.NavigateTo("/dashboard"); await Task.CompletedTask; } }, new CommandItem { - Title = "Documentation", + Title = "Documentation", + Group = "Resources", Description = "View ShellUI documentation", Action = async () => { NavigationManager.NavigateTo("/docs"); await Task.CompletedTask; } }, new CommandItem { - Title = "Blog", + Title = "Blog", + Group = "Resources", Description = "Read our latest blog posts", Action = async () => { NavigationManager.NavigateTo("/blog"); await Task.CompletedTask; } }, new CommandItem { - Title = "Showcase", + Title = "Showcase", + Group = "Community", Description = "See ShellUI in action", Action = async () => { NavigationManager.NavigateTo("/showcase"); await Task.CompletedTask; } }, new CommandItem { - Title = "Sponsors", + Title = "Sponsors", + Group = "Community", Description = "Support ShellUI development", Action = async () => { NavigationManager.NavigateTo("/sponsors"); await Task.CompletedTask; } }, new CommandItem { - Title = "GitHub", + Title = "GitHub", + Group = "Community", Description = "View source code on GitHub", Action = async () => await JSRuntime.InvokeVoidAsync("ShellUI.openUrl", "https://github.com/shellui-dev/shellui", "_blank") } @@ -213,10 +219,6 @@ _isCommandPaletteOpen = true; } - private void OnCommandPaletteChanged(bool isOpen) - { - _isCommandPaletteOpen = isOpen; - } private async Task HandleCommandSelected(CommandItem command) { @@ -226,14 +228,4 @@ await command.Action(); } } - - private void HandleKeyDown(KeyboardEventArgs e) - { - // Ctrl+K to open command palette - if (e.CtrlKey && e.Key == "k") - { - _isCommandPaletteOpen = !_isCommandPaletteOpen; - StateHasChanged(); - } - } } \ No newline at end of file diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor b/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor index 9d08cb4..23fff23 100644 --- a/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor +++ b/NET10/BlazorInteractiveServer/Components/Pages/Components/Index.razor @@ -11,6 +11,11 @@

Browse ShellUI components with usage examples and variants.

+ +

New in 0.4 →

+

Chat, StatCard, TreeView, Timeline, ToggleGroup, ButtonGroup, InputGroup, NumberInput, QrCode, ImageViewer, AspectRatio and Kbd.

+
+

Form

diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Components/NewComponents.razor b/NET10/BlazorInteractiveServer/Components/Pages/Components/NewComponents.razor new file mode 100644 index 0000000..b5ce4f4 --- /dev/null +++ b/NET10/BlazorInteractiveServer/Components/Pages/Components/NewComponents.razor @@ -0,0 +1,14 @@ +@page "/components/new" +@layout BlazorInteractiveServer.Components.Layout.DashboardLayout +@rendermode InteractiveServer +@using BlazorInteractiveServer.Components.Demo + +New in 0.4 | ShellUI + +
+
+

New in 0.4

+

Kbd, AspectRatio, ButtonGroup, ToggleGroup, InputGroup, NumberInput, StatCard, Timeline, TreeView, QrCode, ImageViewer and Chat.

+
+ +
diff --git a/NET10/BlazorInteractiveServer/Components/Pages/Home.razor b/NET10/BlazorInteractiveServer/Components/Pages/Home.razor index c0ede4d..27f9da2 100644 --- a/NET10/BlazorInteractiveServer/Components/Pages/Home.razor +++ b/NET10/BlazorInteractiveServer/Components/Pages/Home.razor @@ -27,6 +27,10 @@
+
+

New in 0.4

+ +
diff --git a/NET10/BlazorInteractiveServer/Components/UI/AspectRatio.razor b/NET10/BlazorInteractiveServer/Components/UI/AspectRatio.razor new file mode 100644 index 0000000..02a289b --- /dev/null +++ b/NET10/BlazorInteractiveServer/Components/UI/AspectRatio.razor @@ -0,0 +1,15 @@ +@namespace BlazorInteractiveServer.Components.UI + +
*]:absolute [&>*]:inset-0 [&>*]:h-full [&>*]:w-full", Class)" style="aspect-ratio: @RatioCss" @attributes="AdditionalAttributes"> + @ChildContent +
+ +@code { + [Parameter] public double Ratio { get; set; } = 16d / 9d; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private string RatioCss => Ratio.ToString(System.Globalization.CultureInfo.InvariantCulture); +} diff --git a/NET10/BlazorInteractiveServer/Components/UI/ButtonGroup.razor b/NET10/BlazorInteractiveServer/Components/UI/ButtonGroup.razor new file mode 100644 index 0000000..1d5967e --- /dev/null +++ b/NET10/BlazorInteractiveServer/Components/UI/ButtonGroup.razor @@ -0,0 +1,18 @@ +@namespace BlazorInteractiveServer.Components.UI + +
+ @ChildContent +
+ +@code { + [Parameter] public string Orientation { get; set; } = "horizontal"; + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private bool IsVertical => string.Equals(Orientation, "vertical", StringComparison.OrdinalIgnoreCase); + + private const string HorizontalClass = "flex-row [&>*]:rounded-none [&>*:first-child]:rounded-l-md [&>*:last-child]:rounded-r-md [&>*:not(:first-child)]:-ml-px [&>*]:focus-visible:z-10"; + private const string VerticalClass = "flex-col [&>*]:rounded-none [&>*:first-child]:rounded-t-md [&>*:last-child]:rounded-b-md [&>*:not(:first-child)]:-mt-px [&>*]:focus-visible:z-10"; +} diff --git a/NET10/BlazorInteractiveServer/Components/UI/Chart.razor b/NET10/BlazorInteractiveServer/Components/UI/Chart.razor index d49df32..0faff4c 100644 --- a/NET10/BlazorInteractiveServer/Components/UI/Chart.razor +++ b/NET10/BlazorInteractiveServer/Components/UI/Chart.razor @@ -20,9 +20,7 @@ [Parameter] public string? Class { get; set; } [Parameter] public string? Height { get; set; } = "400px"; [Parameter] public string? Width { get; set; } = "100%"; - /// Show ApexCharts' zoom/pan/download toolbar. Off by default (shadcn aesthetic). [Parameter] public bool ShowToolbar { get; set; } - /// Show the series legend. Defaults to true; set false on single-series charts for a cleaner look. [Parameter] public bool ShowLegend { get; set; } = true; [Parameter] public RenderFragment? ChildContent { get; set; } diff --git a/NET10/BlazorInteractiveServer/Components/UI/Chat.razor b/NET10/BlazorInteractiveServer/Components/UI/Chat.razor new file mode 100644 index 0000000..012a3e0 --- /dev/null +++ b/NET10/BlazorInteractiveServer/Components/UI/Chat.razor @@ -0,0 +1,30 @@ +@namespace BlazorInteractiveServer.Components.UI + +
+ @if (Header != null) + { +
@Header
+ } + @* column-reverse keeps the scroll anchored to the newest message without JS *@ +
+
+ @ChildContent +
+
+ @if (Footer != null) + { +
@Footer
+ } +
+ +@code { + [Parameter] public string? Height { get; set; } + [Parameter] public RenderFragment? Header { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public RenderFragment? Footer { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } +} diff --git a/NET10/BlazorInteractiveServer/Components/UI/ChatInput.razor b/NET10/BlazorInteractiveServer/Components/UI/ChatInput.razor new file mode 100644 index 0000000..34eb126 --- /dev/null +++ b/NET10/BlazorInteractiveServer/Components/UI/ChatInput.razor @@ -0,0 +1,94 @@ +@namespace BlazorInteractiveServer.Components.UI + +
+ @if (Actions != null) + { +
@Actions
+ } + + +
+ +@code { + [Parameter] public string? Value { get; set; } + [Parameter] public EventCallback ValueChanged { get; set; } + [Parameter] public EventCallback OnSubmit { get; set; } + [Parameter] public string Placeholder { get; set; } = "Send a message..."; + [Parameter] public bool Disabled { get; set; } + [Parameter] public bool IsLoading { get; set; } + [Parameter] public int Rows { get; set; } = 1; + [Parameter] public RenderFragment? Actions { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private ElementReference _textarea; + private int _inputKey; + private bool _ignoreNextInput; + private bool _refocus; + + private bool CanSubmit => !Disabled && !IsLoading && !string.IsNullOrWhiteSpace(Value); + + // Enter sends; the browser's newline still arrives as the next input event, so it is dropped and the textarea re-created. + private async Task HandleKeyDownAsync(KeyboardEventArgs e) + { + if (e.Key != "Enter" || e.ShiftKey) return; + _ignoreNextInput = true; + await SubmitAsync(); + } + + private async Task HandleInputAsync(ChangeEventArgs e) + { + if (_ignoreNextInput) + { + _ignoreNextInput = false; + _inputKey++; + _refocus = true; + return; + } + Value = e.Value?.ToString(); + await ValueChanged.InvokeAsync(Value); + } + + private async Task SubmitAsync() + { + if (!CanSubmit) return; + var text = Value!.Trim(); + Value = ""; + await ValueChanged.InvokeAsync(Value); + await OnSubmit.InvokeAsync(text); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!_refocus) return; + _refocus = false; + await _textarea.FocusAsync(); + } +} diff --git a/NET10/BlazorInteractiveServer/Components/UI/ChatMessage.razor b/NET10/BlazorInteractiveServer/Components/UI/ChatMessage.razor new file mode 100644 index 0000000..d27817d --- /dev/null +++ b/NET10/BlazorInteractiveServer/Components/UI/ChatMessage.razor @@ -0,0 +1,65 @@ +@namespace BlazorInteractiveServer.Components.UI + +
+ @if (Avatar != null) + { +
@Avatar
+ } +
+ @if (!string.IsNullOrEmpty(Name) || !string.IsNullOrEmpty(Timestamp)) + { +
+ @if (!string.IsNullOrEmpty(Name)) + { + @Name + } + @if (!string.IsNullOrEmpty(Timestamp)) + { + @Timestamp + } +
+ } +
+ @if (Text != null) + { + @Text + } + @ChildContent + @if (IsStreaming) + { + + + + + + } +
+ @if (Actions != null) + { +
@Actions
+ } +
+
+ +@code { + [Parameter] public string Role { get; set; } = "assistant"; + [Parameter] public string? Text { get; set; } + [Parameter] public string? Name { get; set; } + [Parameter] public string? Timestamp { get; set; } + [Parameter] public bool IsStreaming { get; set; } + [Parameter] public RenderFragment? Avatar { get; set; } + [Parameter] public RenderFragment? Actions { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public string? Class { get; set; } + [Parameter(CaptureUnmatchedValues = true)] + public Dictionary? AdditionalAttributes { get; set; } + + private bool IsUser => string.Equals(Role, "user", StringComparison.OrdinalIgnoreCase); + + private string BubbleClass => Role.ToLowerInvariant() switch + { + "user" => "bg-primary text-primary-foreground", + "system" => "border border-border bg-background italic text-muted-foreground", + _ => "bg-muted text-foreground" + }; +} diff --git a/NET10/BlazorInteractiveServer/Components/UI/Command.razor b/NET10/BlazorInteractiveServer/Components/UI/Command.razor index 0fa7813..ed81025 100644 --- a/NET10/BlazorInteractiveServer/Components/UI/Command.razor +++ b/NET10/BlazorInteractiveServer/Components/UI/Command.razor @@ -1,172 +1,198 @@ -@using Microsoft.JSInterop @using BlazorInteractiveServer.Components.Models - - - -
- - +@if (IsOpen) +{ +
+
- -@code { - [Parameter] - public bool IsOpen { get; set; } - [Parameter] - public string Placeholder { get; set; } = "Type a command or search..."; - - [Parameter] - public List Commands { get; set; } = new(); +
+ + ↑ + ↓ + Navigate + + + ↵ + Select + + @_filtered.Count @(_filtered.Count == 1 ? "result" : "results") +
+
+} - [Parameter] - public EventCallback IsOpenChanged { get; set; } +@code { + [Parameter] public bool IsOpen { get; set; } + [Parameter] public EventCallback IsOpenChanged { get; set; } + [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 EventCallback CommandSelected { get; set; } + private readonly string _listId = $"command-list-{Guid.NewGuid():N}"; + private readonly Dictionary _itemRefs = new(); + private ElementReference _input; + private List _filtered = new(); + private string _query = ""; + private int _active; + private bool _wasOpen; + private bool _focusInput; + private bool _scrollToActive; - private string _searchQuery = ""; - private List _filteredCommands = new(); - private ElementReference _searchInput; - private bool _shouldFocus; + private string ItemId(int index) => $"{_listId}-{index}"; protected override void OnParametersSet() { - UpdateFilteredCommands(); - if (IsOpen) + if (IsOpen && !_wasOpen) { - _shouldFocus = true; + _query = ""; + _active = 0; + _focusInput = true; } + _wasOpen = IsOpen; + Filter(); } - private void UpdateFilteredCommands() + private void OnInput(ChangeEventArgs e) { - if (string.IsNullOrWhiteSpace(_searchQuery)) - { - _filteredCommands = Commands.ToList(); - } - else - { - _filteredCommands = Commands - .Where(c => - c.Title.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) || - (c.Description?.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase) == true)) + _query = e.Value?.ToString() ?? ""; + _active = 0; + Filter(); + } + + private void Filter() + { + var query = _query.Trim(); + _filtered = query.Length == 0 + ? Commands.ToList() + : Commands + .Where(c => c.Title.Contains(query, StringComparison.OrdinalIgnoreCase) + || c.Description?.Contains(query, StringComparison.OrdinalIgnoreCase) == true + || c.Group?.Contains(query, StringComparison.OrdinalIgnoreCase) == true) + .OrderBy(c => c.Title.StartsWith(query, StringComparison.OrdinalIgnoreCase) ? 0 : 1) .ToList(); - } + if (_active >= _filtered.Count) _active = Math.Max(0, _filtered.Count - 1); } - protected override async Task OnAfterRenderAsync(bool firstRender) + private void SetActive(int index) => _active = index; + + private void Move(int index) + { + if (_filtered.Count == 0) return; + _active = (index + _filtered.Count) % _filtered.Count; + _scrollToActive = true; + } + + private async Task OnKeyDownAsync(KeyboardEventArgs e) { - if (_shouldFocus) + switch (e.Key) { - _shouldFocus = false; - try - { - await _searchInput.FocusAsync(); - } - catch { } + case "ArrowDown": Move(_active + 1); break; + case "ArrowUp": Move(_active - 1); break; + case "Home": Move(0); break; + case "End": Move(_filtered.Count - 1); break; + case "Enter": + if (_filtered.Count > 0) await SelectAsync(_filtered[_active]); + break; + case "Escape": await CloseAsync(); break; } } - private async Task SelectCommand(CommandItem command) + private async Task SelectAsync(CommandItem command) { + await CloseAsync(); await CommandSelected.InvokeAsync(command); - await CloseCommand(); } - private async Task CloseCommand() + private async Task CloseAsync() { IsOpen = false; - _searchQuery = ""; - await IsOpenChanged.InvokeAsync(IsOpen); - } - - private async Task HandleOpenChanged(bool isOpen) - { - if (!isOpen) - { - await CloseCommand(); - } - else - { - IsOpen = isOpen; - await IsOpenChanged.InvokeAsync(isOpen); - } + _wasOpen = false; + await IsOpenChanged.InvokeAsync(false); } - private async Task OnKeyDown(KeyboardEventArgs e) + protected override async Task OnAfterRenderAsync(bool firstRender) { - if (e.Key == "Escape") + if (!IsOpen) return; + if (_focusInput) { - await CloseCommand(); + _focusInput = false; + await _input.FocusAsync(); } - else if (e.Key == "Enter" && _filteredCommands.Any()) + if (_scrollToActive && _itemRefs.TryGetValue(_active, out var item)) { - await SelectCommand(_filteredCommands.First()); - } - else if (e.Key == "ArrowDown" && _filteredCommands.Count > 1) - { - // Could implement keyboard navigation here - } - else if (e.Key == "ArrowUp" && _filteredCommands.Count > 1) - { - // Could implement keyboard navigation here + // Focusing the row scrolls it into view without custom JS; focus then returns to the input. + _scrollToActive = false; + await item.FocusAsync(preventScroll: false); + await _input.FocusAsync(preventScroll: true); } } } - diff --git a/NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor b/NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor index c6126c7..67b9356 100644 --- a/NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor +++ b/NET10/BlazorInteractiveServer/Components/UI/CommandPalette.razor @@ -20,10 +20,20 @@ [Parameter] public bool UseMeta { get; set; } = true; [Parameter] public bool UseShift { get; set; } [Parameter] public bool UseAlt { get; set; } + [Parameter] public bool IsOpen { get; set; } + [Parameter] public EventCallback IsOpenChanged { get; set; } private bool _isOpen; private DotNetObjectReference? _selfRef; private readonly string _handle = Guid.NewGuid().ToString("N"); + private bool _lastIsOpen; + + protected override void OnParametersSet() + { + if (IsOpen == _lastIsOpen) return; + _lastIsOpen = IsOpen; + _isOpen = IsOpen; + } protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -37,17 +47,17 @@ } [JSInvokable] - public Task OnShortcut() + public async Task OnShortcut() { _isOpen = !_isOpen; + await IsOpenChanged.InvokeAsync(_isOpen); StateHasChanged(); - return Task.CompletedTask; } - private Task OnIsOpenChanged(bool open) + private async Task OnIsOpenChanged(bool open) { _isOpen = open; - return Task.CompletedTask; + await IsOpenChanged.InvokeAsync(open); } private async Task OnCommandSelected(CommandItem item) diff --git a/NET10/BlazorInteractiveServer/Components/UI/DialogContent.razor b/NET10/BlazorInteractiveServer/Components/UI/DialogContent.razor index ca643c5..fe5b4b5 100644 --- a/NET10/BlazorInteractiveServer/Components/UI/DialogContent.razor +++ b/NET10/BlazorInteractiveServer/Components/UI/DialogContent.razor @@ -6,7 +6,7 @@ @if (Dialog?.Open == true) {
-
+