diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index d09aaa7..e7ef8d7 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -112,6 +112,7 @@ Same names, properties and variants as MAUI, except: | Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` | | Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) | | Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) | +| ChatInput | The send button sends | Also Enter sends and Shift+Enter starts a new line | | DataTable | Tap a header to sort | Sortable headers are also tab stops (Enter / Space) and keep focus after sorting; a click on a row's checkbox does not raise `RowTapped` | | Command | Enter picks the highlighted item (the first match) | Up / Down move the highlight and Enter picks it; `Value`, `Text` and the other strings are nullable; children are collected in `Groups` and `Items` | | CommandPalette | Open it from code | Also `Hotkey`, a key gesture that toggles it anywhere in the window (Ctrl+K by default; `Hotkey="{x:Null}"` for none) | @@ -367,6 +368,39 @@ shellui-native add tag-input # also installs wrap-layout --- +### Form +Fields stacked with even spacing. Each `FormField` is a label, a control, an optional +description and an error message. Validation is yours: set a field's `Error` and the form +reports it. + +```bash +shellui-native add form +``` + +**Properties (Form):** `Spacing` (24), `IsValid` (no field has an `Error`), `FirstInvalid` +(read-only) · **Methods:** `FormFields()`, `ClearErrors()` + +**Properties (FormField):** `Label`, `Description`, `Error` (two-way; shown in the destructive +color, and the label turns red), `IsRequired` (a red asterisk), `HasError` (read-only) · its +child view is the control + +```xml + + + + + + +``` + +```csharp +EmailField.Error = Email.Text.Contains('@') ? "" : "Enter a valid email address."; +Email.HasError = EmailField.HasError; // the red border is the control's own property +if (SignUp.IsValid) { /* submit */ } +``` + +--- + ### Textarea Multi-line text field — min 80px, grows with content. @@ -1237,6 +1271,49 @@ InvoiceTable.Columns[0].Value = item => ((Invoice)item).Number; --- +### Chat +A conversation panel: an optional header, a scrolling list of messages that follows the newest +one, and an optional footer, usually a `ChatInput`. Give it a height. + +```bash +shellui-native add chat +``` + +**Properties (Chat):** `Header`, `Footer` (views), `AutoScroll` (true: follows new messages and +a message that grows, unless the reader has scrolled up), `Messages` · **Methods:** +`ScrollToEnd()` + +**Properties (ChatMessage):** `Role` (Assistant, User, System), `Text`, `Sender`, `Timestamp`, +`IsStreaming` (three bouncing dots while a reply is arriving), `AvatarText` (initials), +`Avatar` (a view; replaces the initials), `Actions` (a view under the bubble) · its child view +goes inside the bubble, under the text + +**Properties (ChatInput):** `Text` (two-way), `Placeholder`, `IsLoading` (a spinner in the send +button; blocks sending), `Actions` (a view before the text) · **Events:** `Submitted` (the +trimmed text; the box is cleared) · **Methods:** `Submit()`, `Focus()` + +```xml + + + + + + + +``` + +```csharp +private void OnSend(object? sender, string text) +{ + Conversation.Messages.Add(new ChatMessage { Role = ChatRole.User, Text = text }); + var reply = new ChatMessage { Role = ChatRole.Assistant, IsStreaming = true }; + Conversation.Messages.Add(reply); + // later: reply.Text = "..."; reply.IsStreaming = false; +} +``` + +--- + ### Carousel One slide at a time in a rounded frame. Swipe, use the round arrow buttons or tap a dot to move. Each child view is a slide. diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index 1e321c5..9f95050 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -4,7 +4,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [Shell Last revised: **2026-10-11** (second prerelease, `0.1.0-alpha.2`). -**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table. +**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table, chat, form. --- @@ -296,8 +296,9 @@ resizable, navbar, sidebar, date-range-picker and file-upload on both platforms **After `0.1.0-alpha.2`, from ShellUI's Blazor set** (both platforms): button-group ✅, input-group ✅ and menubar ✅ (2026-10-11), then command ✅ (with a command palette) and navigation-menu ✅, then data-table ✅ (client-side filter, sort, selection and pages; -2026-10-12; 69 families, 100 CLI targets). +2026-10-12), then chat ✅ and form ✅ (2026-10-13; 71 families, 102 CLI targets). -**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (chat, form, -image-viewer, qr-code, …). Not in the native data-table yet: row actions and server-side +**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (image-viewer, +prev-next-nav, …). qr-code waits, like the icon switch, for NuGet package dependencies in the +CLI: it needs a QR encoder. Not in the native data-table yet: row actions and server-side paging. diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 90d3d5c..3e11e51 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -19,8 +19,9 @@ into it, not directly into `main`. main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avalonia (PRs #7–#13), layout, navigation and pickers on both platforms (PR #14) merged release 0.1.0-alpha.2 (PR #15), CI action bumps (PR #16), button-group, - input-group and menubar (PR #17), command and navigation-menu (PR #18) merged - └─ feat/data-table ← data-table from ShellUI's Blazor set (active) + input-group and menubar (PR #17), command and navigation-menu (PR #18), + data-table (PR #19) merged + └─ feat/chat-form ← chat and form from ShellUI's Blazor set (active) └─ feat/winui ← Phase 3 (conditional) ``` @@ -37,8 +38,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17 and #18 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | -| `feat/data-table` | `main` | **Active** | data-table for MAUI and Avalonia (103 registry entries, 100 CLI targets, 69 families) | +| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#19 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content | +| `feat/chat-form` | `main` | **Active** | chat and form for MAUI and Avalonia (105 registry entries, 102 CLI targets, 71 families) | --- @@ -532,6 +533,9 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. - [x] data-table (`feat/data-table`): DataTable + DataTableColumn on top of `table`, `input`, `checkbox` and `button`; columns read item properties by name (reflection, with a `Value` function for trimmed apps); no row actions or server-side paging yet +- [x] chat and form (`feat/chat-form`): Chat, ChatMessage and ChatInput (the message's name + property is `Sender` on both platforms, since Avalonia controls already have a `Name`); Form + and FormField, where validation stays with the app and the form only reports field errors - [ ] Replace the generated `Icon.cs` with the `ShellIcons.Maui` / `ShellIcons.Avalonia` packages once they are on NuGet. Their names already match (`IconName`, `Icon`, `Kind` on Avalonia), so components change little: token tinting binds the package's `Color` (MAUI) or `Foreground` diff --git a/examples/Avalonia.Demo/Components/UI/Chat.cs b/examples/Avalonia.Demo/Components/UI/Chat.cs new file mode 100644 index 0000000..62f8ca1 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Chat.cs @@ -0,0 +1,494 @@ +using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; +using Avalonia.Threading; +using Avalonia.VisualTree; +using AvaloniaDemo.Components.UI.Variants; + +namespace AvaloniaDemo.Components.UI; + +// Chat — a rounded-lg bordered panel: an optional header, a scrolling list of messages that +// follows the newest one, and an optional footer (usually a ChatInput). Give it a height. +// +// ...any control... +// +// +// +// +// +// +// Add messages from code with Conversation.Messages.Add(new ChatMessage { ... }). +public class Chat : Border +{ + public static readonly StyledProperty HeaderProperty = + AvaloniaProperty.Register(nameof(Header)); + + public static readonly StyledProperty FooterProperty = + AvaloniaProperty.Register(nameof(Footer)); + + // Scrolls to the newest message when one is added. + public static readonly StyledProperty AutoScrollProperty = + AvaloniaProperty.Register(nameof(AutoScroll), true); + + private readonly StackPanel _messages = new() { Spacing = 16, Margin = new Thickness(16) }; + private readonly ScrollViewer _scroll; + private readonly Border _header = new() { Padding = new Thickness(16, 12), BorderThickness = new Thickness(0, 0, 0, 1), IsVisible = false }; + private readonly Border _footer = new() { Padding = new Thickness(12), BorderThickness = new Thickness(0, 1, 0, 0), IsVisible = false }; + private double _lastHeight; + + static Chat() + { + HeaderProperty.Changed.AddClassHandler((c, _) => c.UpdateSlots()); + FooterProperty.Changed.AddClassHandler((c, _) => c.UpdateSlots()); + } + + public Control? Header + { + get => GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public Control? Footer + { + get => GetValue(FooterProperty); + set => SetValue(FooterProperty, value); + } + + public bool AutoScroll + { + get => GetValue(AutoScrollProperty); + set => SetValue(AutoScrollProperty, value); + } + + // The ChatMessage children. + [Content] + public Controls Messages => _messages.Children; + + public Chat() + { + _scroll = new ScrollViewer { Content = _messages, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled }; + _header.Token(BorderBrushProperty, ShellToken.Border); + _footer.Token(BorderBrushProperty, ShellToken.Border); + Grid.SetRow(_scroll, 1); + Grid.SetRow(_footer, 2); + + Child = new Grid { RowDefinitions = new RowDefinitions("Auto,*,Auto"), Children = { _header, _scroll, _footer } }; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + ClipToBounds = true; + this.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border); + + _messages.Children.CollectionChanged += (_, _) => { if (AutoScroll) ScrollToEnd(); }; + // A message that grows (a reply still arriving) keeps the list at the end, unless the + // reader has scrolled up. + _messages.SizeChanged += (_, e) => + { + var wasAtEnd = _scroll.Offset.Y >= _lastHeight - _scroll.Viewport.Height - 24; + _lastHeight = e.NewSize.Height + 32; + if (AutoScroll && wasAtEnd) ScrollToEnd(); + }; + } + + // Shows the newest message, once a message added just now has its size. + public void ScrollToEnd() => Dispatcher.UIThread.Post(() => _scroll.ScrollToEnd(), DispatcherPriority.Loaded); + + private void UpdateSlots() + { + _header.Child = Header; + _header.IsVisible = Header != null; + _footer.Child = Footer; + _footer.IsVisible = Footer != null; + } +} + +public enum ChatRole { Assistant, User, System } + +// One message — an optional avatar beside a bubble (rounded-lg px-3 py-2 text-sm, at most 80% +// of the width). User messages sit on the right in bg-primary, assistant ones on the left in +// bg-muted, system ones are outlined and italic. Its child control goes inside the bubble. +public class ChatMessage : Border +{ + public static readonly StyledProperty RoleProperty = + AvaloniaProperty.Register(nameof(Role)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // The sender's name above the bubble. (Avalonia controls already have a Name.) + public static readonly StyledProperty SenderProperty = + AvaloniaProperty.Register(nameof(Sender)); + + public static readonly StyledProperty TimestampProperty = + AvaloniaProperty.Register(nameof(Timestamp)); + + // Shows three bouncing dots after the text while a reply is still arriving. + public static readonly StyledProperty IsStreamingProperty = + AvaloniaProperty.Register(nameof(IsStreaming)); + + // Initials shown in the avatar circle. + public static readonly StyledProperty AvatarTextProperty = + AvaloniaProperty.Register(nameof(AvatarText)); + + // Any control for the avatar circle (an Avatar, an Icon); replaces AvatarText. + public static readonly StyledProperty AvatarProperty = + AvaloniaProperty.Register(nameof(Avatar)); + + // A control inside the bubble, under the text. + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + // A control under the bubble (copy, retry, ...). + public static readonly StyledProperty ActionsProperty = + AvaloniaProperty.Register(nameof(Actions)); + + private readonly Grid _row; + private readonly Border _avatar; + private readonly TextBlock _avatarText; + private readonly StackPanel _column; + private readonly StackPanel _meta; + private readonly TextBlock _sender; + private readonly TextBlock _timestamp; + private readonly Border _bubble; + private readonly TextBlock _text; + private readonly Border _body = new(); + private readonly StackPanel _dots; + private readonly Border _actions = new() { IsVisible = false }; + private DispatcherTimer? _timer; + private double _phase; + + static ChatMessage() + { + RoleProperty.Changed.AddClassHandler((m, _) => m.Update()); + TextProperty.Changed.AddClassHandler((m, _) => m.Update()); + SenderProperty.Changed.AddClassHandler((m, _) => m.Update()); + TimestampProperty.Changed.AddClassHandler((m, _) => m.Update()); + IsStreamingProperty.Changed.AddClassHandler((m, _) => m.Update()); + AvatarTextProperty.Changed.AddClassHandler((m, _) => m.Update()); + AvatarProperty.Changed.AddClassHandler((m, _) => m.Update()); + ActionsProperty.Changed.AddClassHandler((m, _) => m.Update()); + BodyProperty.Changed.AddClassHandler((m, e) => m._body.Child = e.NewValue as Control); + } + + public ChatRole Role + { + get => GetValue(RoleProperty); + set => SetValue(RoleProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Sender + { + get => GetValue(SenderProperty); + set => SetValue(SenderProperty, value); + } + + public string? Timestamp + { + get => GetValue(TimestampProperty); + set => SetValue(TimestampProperty, value); + } + + public bool IsStreaming + { + get => GetValue(IsStreamingProperty); + set => SetValue(IsStreamingProperty, value); + } + + public string? AvatarText + { + get => GetValue(AvatarTextProperty); + set => SetValue(AvatarTextProperty, value); + } + + public Control? Avatar + { + get => GetValue(AvatarProperty); + set => SetValue(AvatarProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public Control? Actions + { + get => GetValue(ActionsProperty); + set => SetValue(ActionsProperty, value); + } + + public ChatMessage() + { + _avatarText = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _avatarText.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + // h-8 w-8 rounded-full bg-muted, clipped to the circle. + _avatar = new Border { Width = 32, Height = 32, CornerRadius = new CornerRadius(16), ClipToBounds = true, VerticalAlignment = VerticalAlignment.Top, IsVisible = false }; + _avatar.Token(BackgroundProperty, ShellToken.Muted); + + _sender = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium }; + _sender.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _timestamp = new TextBlock { FontSize = 12 }; + _timestamp.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _meta = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Children = { _sender, _timestamp } }; + + _text = new TextBlock { FontSize = 14, LineHeight = 20, TextWrapping = TextWrapping.Wrap }; + _dots = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 3, Height = 14, IsVisible = false }; + for (var i = 0; i < 3; i++) + _dots.Children.Add(new Border { Width = 6, Height = 6, CornerRadius = new CornerRadius(3), VerticalAlignment = VerticalAlignment.Bottom, RenderTransform = new TranslateTransform() }); + AutomationProperties.SetName(_dots, "Typing"); + + _bubble = new Border + { + Child = new StackPanel { Spacing = 6, Children = { _text, _body, _dots } }, + Padding = new Thickness(12, 8), + CornerRadius = new CornerRadius(ShellTheme.RadiusLg) + }; + _column = new StackPanel { Spacing = 4, Children = { _meta, _bubble, _actions } }; + Grid.SetColumn(_column, 1); + + _row = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 12, Children = { _column, _avatar } }; + // max-w-[80%] + _row.SizeChanged += (_, e) => _column.MaxWidth = Math.Max(120, e.NewSize.Width * 0.8); + Child = _row; + Update(); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + UpdateDots(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + StopDots(); + } + + private void Update() + { + var user = Role == ChatRole.User; + var side = user ? HorizontalAlignment.Right : HorizontalAlignment.Left; + + _avatar.Child = Avatar ?? (string.IsNullOrEmpty(AvatarText) ? null : _avatarText); + _avatarText.Text = AvatarText ?? string.Empty; + _avatar.IsVisible = _avatar.Child != null; + Grid.SetColumn(_avatar, user ? 2 : 0); + + _column.HorizontalAlignment = side; + _meta.HorizontalAlignment = side; + _bubble.HorizontalAlignment = side; + _actions.HorizontalAlignment = side; + + _sender.Text = Sender ?? string.Empty; + _sender.IsVisible = !string.IsNullOrEmpty(Sender); + _timestamp.Text = Timestamp ?? string.Empty; + _timestamp.IsVisible = !string.IsNullOrEmpty(Timestamp); + _meta.IsVisible = _sender.IsVisible || _timestamp.IsVisible; + + var (background, foreground) = Role switch + { + ChatRole.User => (ShellToken.Primary, ShellToken.PrimaryForeground), + ChatRole.System => (ShellToken.Background, ShellToken.MutedForeground), + _ => (ShellToken.Muted, ShellToken.Foreground) + }; + _bubble.Token(BackgroundProperty, background).Token(BorderBrushProperty, ShellToken.Border); + _bubble.BorderThickness = new Thickness(Role == ChatRole.System ? 1 : 0); + _text.Text = Text ?? string.Empty; + _text.IsVisible = !string.IsNullOrEmpty(Text); + _text.FontStyle = Role == ChatRole.System ? FontStyle.Italic : FontStyle.Normal; + _text.Token(TextBlock.ForegroundProperty, foreground); + foreach (var dot in _dots.Children) ((Border)dot).Token(BackgroundProperty, foreground); + + _actions.Child = Actions; + _actions.IsVisible = Actions != null; + _dots.IsVisible = IsStreaming; + UpdateDots(); + } + + // Each dot hops in turn, like animate-bounce with staggered delays; one cycle per second. + private void UpdateDots() + { + if (!IsStreaming || !this.IsAttachedToVisualTree()) + { + StopDots(); + return; + } + if (_timer != null) return; + _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(16), DispatcherPriority.Render, (_, _) => + { + _phase = (_phase + 0.016) % 1; + for (var i = 0; i < _dots.Children.Count; i++) + { + var t = (_phase - i * 0.15 + 1) % 1; + var hop = t < 0.6 ? Math.Sin(t / 0.6 * Math.PI) : 0; + ((TranslateTransform)_dots.Children[i].RenderTransform!).Y = -5 * hop; + } + }); + _timer.Start(); + } + + private void StopDots() + { + _timer?.Stop(); + _timer = null; + foreach (var dot in _dots.Children) ((TranslateTransform)dot.RenderTransform!).Y = 0; + } +} + +// Message box — rounded-lg border border-input p-2: a text area that grows with its text (up to +// max-h-48) and a send button. Enter sends and Shift+Enter starts a new line; sending raises +// Submitted with the trimmed text and clears the box. +// +public class ChatInput : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Send a message..."); + + // Shows a spinner in the send button and blocks sending, while a reply is on its way. + public static readonly StyledProperty IsLoadingProperty = + AvaloniaProperty.Register(nameof(IsLoading)); + + // A control before the text (an attach button, say). + public static readonly StyledProperty ActionsProperty = + AvaloniaProperty.Register(nameof(Actions)); + + private readonly TextBox _textBox; + private readonly Button _send; + private readonly Border _actions = new() { VerticalAlignment = VerticalAlignment.Bottom, IsVisible = false }; + + static ChatInput() + { + TextProperty.Changed.AddClassHandler((i, e) => + { + if (i._textBox.Text != (string?)e.NewValue) i._textBox.Text = (string?)e.NewValue; + i.UpdateSend(); + }); + PlaceholderProperty.Changed.AddClassHandler((i, e) => i._textBox.PlaceholderText = (string?)e.NewValue); + IsLoadingProperty.Changed.AddClassHandler((i, _) => i.UpdateSend()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => + { + i.Opacity = i.IsEnabled ? 1.0 : 0.5; + i.UpdateSend(); + }); + ActionsProperty.Changed.AddClassHandler((i, e) => + { + i._actions.Child = e.NewValue as Control; + i._actions.IsVisible = e.NewValue != null; + }); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public bool IsLoading + { + get => GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public Control? Actions + { + get => GetValue(ActionsProperty); + set => SetValue(ActionsProperty, value); + } + + // Raised with the trimmed text. + public event EventHandler? Submitted; + + public ChatInput() + { + _textBox = new TextBox + { + FontSize = 14, + PlaceholderText = Placeholder, + AcceptsReturn = true, + TextWrapping = TextWrapping.Wrap, + MaxHeight = 192, + VerticalAlignment = VerticalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + ShellPlatform.StripNativeChrome(_textBox); + _textBox.MinHeight = 36; // after StripNativeChrome, which clears Fluent's minimum + _textBox.TextChanged += (_, _) => Text = _textBox.Text; + // Tunnel: with AcceptsReturn the text box would turn Enter into a new line. + _textBox.AddHandler(KeyDownEvent, OnTextKeyDown, RoutingStrategies.Tunnel); + _textBox.GotFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Ring); + _textBox.LostFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Input); + Grid.SetColumn(_textBox, 1); + + _send = new Button { Icon = IconName.ArrowRight, Size = ButtonSize.Icon, VerticalAlignment = VerticalAlignment.Bottom }; + AutomationProperties.SetName(_send, "Send"); + _send.Clicked += (_, _) => Submit(); + Grid.SetColumn(_send, 2); + + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8, Children = { _actions, _textBox, _send } }; + Padding = new Thickness(8); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + Cursor = new Cursor(StandardCursorType.Ibeam); + this.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Input); + UpdateSend(); + } + + private bool CanSubmit => IsEffectivelyEnabled && !IsLoading && !string.IsNullOrWhiteSpace(Text); + + // Sends the text as the button or Enter does. Does nothing while loading or when the box is empty. + public void Submit() + { + if (!CanSubmit) return; + var text = Text!.Trim(); + Text = string.Empty; + Submitted?.Invoke(this, text); + } + + public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) + => _textBox.Focus(method, keyModifiers); + + // Clicking the padding focuses the text box too. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!e.Handled && !_textBox.IsFocused) _textBox.Focus(); + } + + private void OnTextKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift)) return; + Submit(); + e.Handled = true; + } + + private void UpdateSend() + { + _send.IsLoading = IsLoading; + _send.IsEnabled = CanSubmit || IsLoading; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Form.cs b/examples/Avalonia.Demo/Components/UI/Form.cs new file mode 100644 index 0000000..7e7c665 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Form.cs @@ -0,0 +1,165 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Layout; +using Avalonia.LogicalTree; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Form — fields stacked with space-y-6. Each FormField is a label, a control, an optional +// description and an error message; validation is yours: set a field's Error and the form +// reports it. +// +// +// +// +// +// +// EmailField.Error = Email.Text.Contains('@') ? "" : "Enter a valid email."; +// if (SignUp.IsValid) ... +public class Form : Border +{ + public static readonly StyledProperty SpacingProperty = + AvaloniaProperty.Register(nameof(Spacing), 24); + + private readonly StackPanel _stack = new() { Spacing = 24 }; + + static Form() + { + SpacingProperty.Changed.AddClassHandler
((f, e) => f._stack.Spacing = (double)e.NewValue!); + } + + public double Spacing + { + get => GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + // FormFields and anything else the form holds (buttons, separators). + [Content] + public Controls Fields => _stack.Children; + + // True when no field in the form has an Error. + public bool IsValid => FormFields().All(f => !f.HasError); + + // The first field with an Error, top to bottom. + public FormField? FirstInvalid => FormFields().FirstOrDefault(f => f.HasError); + + public Form() + { + Child = _stack; + } + + // Every FormField in the form, however deeply it is nested. + public IEnumerable FormFields() => _stack.GetLogicalDescendants().OfType(); + + public void ClearErrors() + { + foreach (var field in FormFields()) field.Error = null; + } +} + +// One field of a Form — space-y-2: a text-sm font-medium label, the control, a muted +// description and, when Error is set, the error in text-destructive (the label turns red too). +public class FormField : Border +{ + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + // The validation message; empty when the field is valid. + public static readonly StyledProperty ErrorProperty = + AvaloniaProperty.Register(nameof(Error), defaultBindingMode: BindingMode.TwoWay); + + // Adds a red asterisk after the label. + public static readonly StyledProperty IsRequiredProperty = + AvaloniaProperty.Register(nameof(IsRequired)); + + public static readonly StyledProperty ControlProperty = + AvaloniaProperty.Register(nameof(Control)); + + private readonly TextBlock _label; + private readonly TextBlock _required; + private readonly StackPanel _labelRow; + private readonly Border _control = new(); + private readonly TextBlock _description; + private readonly TextBlock _error; + + static FormField() + { + LabelProperty.Changed.AddClassHandler((f, _) => f.Update()); + DescriptionProperty.Changed.AddClassHandler((f, _) => f.Update()); + ErrorProperty.Changed.AddClassHandler((f, _) => f.Update()); + IsRequiredProperty.Changed.AddClassHandler((f, _) => f.Update()); + ControlProperty.Changed.AddClassHandler((f, e) => f._control.Child = e.NewValue as Control); + } + + public string? Label + { + get => GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public string? Error + { + get => GetValue(ErrorProperty); + set => SetValue(ErrorProperty, value); + } + + public bool IsRequired + { + get => GetValue(IsRequiredProperty); + set => SetValue(IsRequiredProperty, value); + } + + [Content] + public Control? Control + { + get => GetValue(ControlProperty); + set => SetValue(ControlProperty, value); + } + + public bool HasError => !string.IsNullOrEmpty(Error); + + public FormField() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium }; + _required = new TextBlock { Text = "*", FontSize = 14, FontWeight = FontWeight.Medium, IsVisible = false }; + _required.Token(TextBlock.ForegroundProperty, ShellToken.Destructive); + _labelRow = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, Children = { _label, _required } }; + _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap, IsVisible = false }; + _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _error = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, TextWrapping = TextWrapping.Wrap, IsVisible = false }; + _error.Token(TextBlock.ForegroundProperty, ShellToken.Destructive); + // Screen readers announce the message when it appears. + AutomationProperties.SetLiveSetting(_error, AutomationLiveSetting.Polite); + + Child = new StackPanel { Spacing = 8, Children = { _labelRow, _control, _description, _error } }; + Update(); + } + + private void Update() + { + _label.Text = Label ?? string.Empty; + _label.Token(TextBlock.ForegroundProperty, HasError ? ShellToken.Destructive : ShellToken.Foreground); + _required.IsVisible = IsRequired; + _labelRow.IsVisible = !string.IsNullOrEmpty(Label); + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + _error.Text = Error ?? string.Empty; + _error.IsVisible = HasError; + } +} diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index bfdf140..b6b4568 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -683,6 +683,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 6246c1e..6b9cc50 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -103,6 +103,26 @@ public MainWindow() DemoDataTable.SelectionChanged += (_, items) => DataTableChoice.Text = $"Data table: {items.Count} selected ({string.Join(", ", items.Cast().Select(i => i.Number))})."; DemoDataTable.RowTapped += (_, item) => DataTableChoice.Text = $"Data table row: {((DemoInvoice)item).Number}."; + DemoChatInput.Submitted += async (_, text) => + { + DemoChat.Messages.Add(new ChatMessage { Role = ChatRole.User, Sender = "Shewart", AvatarText = "SS", Text = text }); + var reply = new ChatMessage { Role = ChatRole.Assistant, Sender = "ShellUI", AvatarText = "S", IsStreaming = true }; + DemoChat.Messages.Add(reply); + DemoChatInput.IsLoading = true; + ChatFormChoice.Text = $"Chat: sent \"{text}\"."; + await System.Threading.Tasks.Task.Delay(1200); + reply.Text = $"You said: {text}"; + reply.IsStreaming = false; + DemoChatInput.IsLoading = false; + }; + DemoFormSubmit.Clicked += (_, _) => + { + DemoEmailField.Error = (DemoEmail.Text ?? string.Empty).Contains('@') ? string.Empty : "Enter a valid email address."; + DemoPasswordField.Error = (DemoPassword.Text ?? string.Empty).Length >= 8 ? string.Empty : "Use at least 8 characters."; + DemoEmail.HasError = DemoEmailField.HasError; + DemoPassword.HasError = DemoPasswordField.HasError; + ChatFormChoice.Text = DemoForm.IsValid ? "Form: valid." : $"Form: {DemoForm.FormFields().Count(f => f.HasError)} error(s), first is {DemoForm.FirstInvalid?.Label}."; + }; DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}."; DemoSidebar.SelectedValueChanged += (_, value) => { diff --git a/examples/MAUI.Demo/Components/UI/Chat.cs b/examples/MAUI.Demo/Components/UI/Chat.cs new file mode 100644 index 0000000..c251d84 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Chat.cs @@ -0,0 +1,515 @@ +using Microsoft.Maui.Controls.Shapes; +using MAUI.Demo.Components.UI.Variants; + +namespace MAUI.Demo.Components.UI; + +// Chat — a rounded-lg bordered panel: an optional header, a scrolling list of messages that +// follows the newest one, and an optional footer (usually a ChatInput). Give it a height. +// +// ...any view... +// +// +// +// +// +// +// Add messages from code with Conversation.Messages.Add(new ChatMessage { ... }). +[ContentProperty(nameof(Messages))] +public partial class Chat : ContentView +{ + public static readonly BindableProperty HeaderProperty = + BindableProperty.Create(nameof(Header), typeof(View), typeof(Chat), null, + propertyChanged: (b, o, n) => ((Chat)b).UpdateSlots()); + + public static readonly BindableProperty FooterProperty = + BindableProperty.Create(nameof(Footer), typeof(View), typeof(Chat), null, + propertyChanged: (b, o, n) => ((Chat)b).UpdateSlots()); + + // Scrolls to the newest message when one is added. + public static readonly BindableProperty AutoScrollProperty = + BindableProperty.Create(nameof(AutoScroll), typeof(bool), typeof(Chat), true); + + public View? Header + { + get => (View?)GetValue(HeaderProperty); + set => SetValue(HeaderProperty, value); + } + + public View? Footer + { + get => (View?)GetValue(FooterProperty); + set => SetValue(FooterProperty, value); + } + + public bool AutoScroll + { + get => (bool)GetValue(AutoScrollProperty); + set => SetValue(AutoScrollProperty, value); + } + + public IList Messages => _messages.Children; + + private readonly VerticalStackLayout _messages; + private readonly ScrollView _scroll; + private readonly ContentView _header; + private readonly ContentView _footer; + private readonly BoxView _headerLine; + private readonly BoxView _footerLine; + private double _lastHeight; + + public Chat() + { + _messages = new VerticalStackLayout { Spacing = 16, Padding = new Thickness(16) }; + _messages.ChildAdded += (_, _) => { if (AutoScroll) ScrollToEnd(); }; + _scroll = new ScrollView { Content = _messages }; + // A message that grows (a reply still arriving) keeps the list at the end, unless the + // reader has scrolled up. + _messages.SizeChanged += (_, _) => + { + var wasAtEnd = _scroll.ScrollY >= _lastHeight - _scroll.Height - 24; + _lastHeight = _messages.Height; + if (AutoScroll && wasAtEnd) ScrollToEnd(animated: false); + }; + _header = new ContentView { Padding = new Thickness(16, 12), IsVisible = false }; + _footer = new ContentView { Padding = new Thickness(12), IsVisible = false }; + _headerLine = Line(); + _footerLine = Line(); + + var grid = new Grid + { + RowDefinitions = + { + new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Star), + new RowDefinition(GridLength.Auto), new RowDefinition(GridLength.Auto) + } + }; + grid.Add(_header, 0, 0); + grid.Add(_headerLine, 0, 1); + grid.Add(_scroll, 0, 2); + grid.Add(_footerLine, 0, 3); + grid.Add(_footer, 0, 4); + + var border = new Border + { + Content = grid, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg } + }; + border.Token(VisualElement.BackgroundColorProperty, ShellToken.Background); + border.Token(Border.StrokeProperty, ShellToken.Border); + Content = border; + } + + // Shows the newest message; waits a moment so a message added just now has its size. + public async void ScrollToEnd(bool animated = true) + { + await Task.Delay(60); + if (_scroll.Handler is null) return; + await _scroll.ScrollToAsync(0, Math.Max(0, _messages.Height - _scroll.Height), animated); + } + + private static BoxView Line() + { + var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent, IsVisible = false }; + line.Token(BoxView.ColorProperty, ShellToken.Border); + return line; + } + + private void UpdateSlots() + { + _header.Content = Header; + _header.IsVisible = _headerLine.IsVisible = Header != null; + _footer.Content = Footer; + _footer.IsVisible = _footerLine.IsVisible = Footer != null; + } +} + +public enum ChatRole { Assistant, User, System } + +// One message — an optional avatar beside a bubble (rounded-lg px-3 py-2 text-sm, at most 80% +// of the width). User messages sit on the right in bg-primary, assistant ones on the left in +// bg-muted, system ones are outlined and italic. Its child view goes inside the bubble. +[ContentProperty(nameof(Body))] +public partial class ChatMessage : ContentView +{ + public static readonly BindableProperty RoleProperty = + BindableProperty.Create(nameof(Role), typeof(ChatRole), typeof(ChatMessage), ChatRole.Assistant, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(ChatMessage), string.Empty, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + // The sender's name above the bubble. + public static readonly BindableProperty SenderProperty = + BindableProperty.Create(nameof(Sender), typeof(string), typeof(ChatMessage), string.Empty, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + public static readonly BindableProperty TimestampProperty = + BindableProperty.Create(nameof(Timestamp), typeof(string), typeof(ChatMessage), string.Empty, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + // Shows three bouncing dots after the text while a reply is still arriving. + public static readonly BindableProperty IsStreamingProperty = + BindableProperty.Create(nameof(IsStreaming), typeof(bool), typeof(ChatMessage), false, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + // Initials shown in the avatar circle. + public static readonly BindableProperty AvatarTextProperty = + BindableProperty.Create(nameof(AvatarText), typeof(string), typeof(ChatMessage), string.Empty, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + // Any view for the avatar circle (an Avatar, an Icon); replaces AvatarText. + public static readonly BindableProperty AvatarProperty = + BindableProperty.Create(nameof(Avatar), typeof(View), typeof(ChatMessage), null, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + // A view inside the bubble, under the text. + public static readonly BindableProperty BodyProperty = + BindableProperty.Create(nameof(Body), typeof(View), typeof(ChatMessage), null, + propertyChanged: (b, o, n) => ((ChatMessage)b)._body.Content = n as View); + + // A view under the bubble (copy, retry, ...). + public static readonly BindableProperty ActionsProperty = + BindableProperty.Create(nameof(Actions), typeof(View), typeof(ChatMessage), null, + propertyChanged: (b, o, n) => ((ChatMessage)b).Update()); + + public ChatRole Role + { + get => (ChatRole)GetValue(RoleProperty); + set => SetValue(RoleProperty, value); + } + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string Sender + { + get => (string)GetValue(SenderProperty); + set => SetValue(SenderProperty, value); + } + + public string Timestamp + { + get => (string)GetValue(TimestampProperty); + set => SetValue(TimestampProperty, value); + } + + public bool IsStreaming + { + get => (bool)GetValue(IsStreamingProperty); + set => SetValue(IsStreamingProperty, value); + } + + public string AvatarText + { + get => (string)GetValue(AvatarTextProperty); + set => SetValue(AvatarTextProperty, value); + } + + public View? Avatar + { + get => (View?)GetValue(AvatarProperty); + set => SetValue(AvatarProperty, value); + } + + public View? Body + { + get => (View?)GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public View? Actions + { + get => (View?)GetValue(ActionsProperty); + set => SetValue(ActionsProperty, value); + } + + private const string DotsAnimation = "ChatDots"; + + private readonly Grid _row; + private readonly Border _avatar; + private readonly Label _avatarText; + private readonly VerticalStackLayout _column; + private readonly HorizontalStackLayout _meta; + private readonly Label _name; + private readonly Label _timestamp; + private readonly Border _bubble; + private readonly Label _text; + private readonly ContentView _body; + private readonly HorizontalStackLayout _dots; + private readonly ContentView _actions; + + public ChatMessage() + { + _avatarText = new Label { FontSize = 12, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = TextAlignment.Center, VerticalTextAlignment = TextAlignment.Center }; + _avatarText.Token(Label.TextColorProperty, ShellToken.Foreground); + // h-8 w-8 rounded-full bg-muted, clipped to the circle. + _avatar = new Border + { + WidthRequest = 32, + HeightRequest = 32, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = 16 }, + VerticalOptions = LayoutOptions.Start, + IsVisible = false + }; + _avatar.Token(VisualElement.BackgroundColorProperty, ShellToken.Muted); + + _name = new Label { FontSize = 12, FontAttributes = FontAttributes.Bold }; + _name.Token(Label.TextColorProperty, ShellToken.Foreground); + _timestamp = new Label { FontSize = 12 }; + _timestamp.Token(Label.TextColorProperty, ShellToken.MutedForeground); + _meta = new HorizontalStackLayout { Spacing = 8, Children = { _name, _timestamp } }; + + _text = new Label { FontSize = 14, LineHeight = 1.3, LineBreakMode = LineBreakMode.WordWrap }; + _body = new ContentView(); + _dots = new HorizontalStackLayout { Spacing = 3, HeightRequest = 14, IsVisible = false }; + for (var i = 0; i < 3; i++) + _dots.Children.Add(new BoxView { WidthRequest = 6, HeightRequest = 6, CornerRadius = 3, VerticalOptions = LayoutOptions.End, BackgroundColor = Colors.Transparent }); + SemanticProperties.SetDescription(_dots, "Typing"); + + _bubble = new Border + { + Content = new VerticalStackLayout { Spacing = 6, Children = { _text, _body, _dots } }, + Padding = new Thickness(12, 8), + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg } + }; + _actions = new ContentView { IsVisible = false }; + _column = new VerticalStackLayout { Spacing = 4, Children = { _meta, _bubble, _actions } }; + + _row = new Grid + { + ColumnSpacing = 12, + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + } + }; + _row.Add(_column, 1, 0); + _row.Add(_avatar); + // max-w-[80%] + _row.SizeChanged += (_, _) => { if (_row.Width > 0) _column.MaximumWidthRequest = Math.Max(120, _row.Width * 0.8); }; + Content = _row; + Update(); + } + + private void Update() + { + var user = Role == ChatRole.User; + var side = user ? LayoutOptions.End : LayoutOptions.Start; + + _avatar.Content = Avatar ?? (string.IsNullOrEmpty(AvatarText) ? null : _avatarText); + _avatarText.Text = AvatarText ?? string.Empty; + _avatar.IsVisible = _avatar.Content != null; + Grid.SetColumn(_avatar, user ? 2 : 0); + + _column.HorizontalOptions = side; + _meta.HorizontalOptions = side; + _bubble.HorizontalOptions = side; + _actions.HorizontalOptions = side; + + _name.Text = Sender ?? string.Empty; + _name.IsVisible = !string.IsNullOrEmpty(Sender); + _timestamp.Text = Timestamp ?? string.Empty; + _timestamp.IsVisible = !string.IsNullOrEmpty(Timestamp); + _meta.IsVisible = _name.IsVisible || _timestamp.IsVisible; + + var (background, foreground) = Role switch + { + ChatRole.User => (ShellToken.Primary, ShellToken.PrimaryForeground), + ChatRole.System => (ShellToken.Background, ShellToken.MutedForeground), + _ => (ShellToken.Muted, ShellToken.Foreground) + }; + _bubble.Token(VisualElement.BackgroundColorProperty, background); + _bubble.StrokeThickness = Role == ChatRole.System ? 1 : 0; + _bubble.Token(Border.StrokeProperty, ShellToken.Border); + _text.Text = Text ?? string.Empty; + _text.IsVisible = !string.IsNullOrEmpty(Text); + _text.FontAttributes = Role == ChatRole.System ? FontAttributes.Italic : FontAttributes.None; + _text.Token(Label.TextColorProperty, foreground); + foreach (var dot in _dots.Children.OfType()) dot.Token(BoxView.ColorProperty, foreground); + + _actions.Content = Actions; + _actions.IsVisible = Actions != null; + + _dots.IsVisible = IsStreaming; + if (IsStreaming) StartDots(); else StopDots(); + } + + // Each dot hops in turn, like animate-bounce with staggered delays. + private void StartDots() + { + if (this.AnimationIsRunning(DotsAnimation)) return; + var dots = _dots.Children.OfType().ToList(); + var animation = new Animation(); + for (var i = 0; i < dots.Count; i++) + { + var dot = dots[i]; + var start = i * 0.15; + animation.Add(start, start + 0.3, new Animation(v => dot.TranslationY = v, 0, -5, Easing.CubicOut)); + animation.Add(start + 0.3, start + 0.6, new Animation(v => dot.TranslationY = v, -5, 0, Easing.CubicIn)); + } + animation.Commit(this, DotsAnimation, 16, 1000, Easing.Linear, repeat: () => IsStreaming); + } + + private void StopDots() + { + this.AbortAnimation(DotsAnimation); + foreach (var dot in _dots.Children.OfType()) dot.TranslationY = 0; + } +} + +// Message box — rounded-lg border border-input p-2: a text area that grows with its text (up to +// max-h-48) and a send button. The button, or Submit(), raises Submitted with the trimmed text +// and clears the box. +// +public partial class ChatInput : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(ChatInput), string.Empty, BindingMode.TwoWay, + propertyChanged: (b, o, n) => + { + var input = (ChatInput)b; + if (input._editor.Text != n as string) input._editor.Text = n as string ?? string.Empty; + input.UpdateSend(); + }); + + public static readonly BindableProperty PlaceholderProperty = + BindableProperty.Create(nameof(Placeholder), typeof(string), typeof(ChatInput), "Send a message...", + propertyChanged: (b, o, n) => ((ChatInput)b)._editor.Placeholder = n as string ?? string.Empty); + + // Shows a spinner in the send button and blocks sending, while a reply is on its way. + public static readonly BindableProperty IsLoadingProperty = + BindableProperty.Create(nameof(IsLoading), typeof(bool), typeof(ChatInput), false, + propertyChanged: (b, o, n) => ((ChatInput)b).UpdateSend()); + + // A view before the text (an attach button, say). + public static readonly BindableProperty ActionsProperty = + BindableProperty.Create(nameof(Actions), typeof(View), typeof(ChatInput), null, + propertyChanged: (b, o, n) => + { + var input = (ChatInput)b; + input._actions.Content = n as View; + input._actions.IsVisible = n != null; + }); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string Placeholder + { + get => (string)GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public bool IsLoading + { + get => (bool)GetValue(IsLoadingProperty); + set => SetValue(IsLoadingProperty, value); + } + + public View? Actions + { + get => (View?)GetValue(ActionsProperty); + set => SetValue(ActionsProperty, value); + } + + // Raised with the trimmed text. + public event EventHandler? Submitted; + + private readonly Border _border; + private readonly Editor _editor; + private readonly Button _send; + private readonly ContentView _actions; + + public ChatInput() + { + _editor = new Editor + { + FontSize = 14, + Placeholder = Placeholder, + BackgroundColor = Colors.Transparent, + MinimumHeightRequest = 36, + MaximumHeightRequest = 192, + AutoSize = EditorAutoSizeOption.TextChanges, + VerticalOptions = LayoutOptions.Center + }; + _editor.Token(Editor.TextColorProperty, ShellToken.Foreground); + _editor.Token(Editor.PlaceholderColorProperty, ShellToken.MutedForeground); + ShellPlatform.StripNativeChrome(_editor); + ShellFocus.Track(_editor); + _editor.TextChanged += (_, e) => Text = e.NewTextValue; + _editor.Focused += (_, _) => _border!.Token(Border.StrokeProperty, ShellToken.Ring); + _editor.Unfocused += (_, _) => _border!.Token(Border.StrokeProperty, ShellToken.Input); + + _send = new Button { Icon = IconName.ArrowRight, Size = ButtonSize.Icon, VerticalOptions = LayoutOptions.End }; + SemanticProperties.SetDescription(_send, "Send"); + _send.Clicked += (_, _) => Submit(); + _actions = new ContentView { VerticalOptions = LayoutOptions.End, IsVisible = false }; + + var row = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + } + }; + row.Add(_actions, 0, 0); + row.Add(_editor, 1, 0); + row.Add(_send, 2, 0); + + _border = new Border + { + Content = row, + Padding = new Thickness(8), + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg } + }; + _border.Token(VisualElement.BackgroundColorProperty, ShellToken.Background); + _border.Token(Border.StrokeProperty, ShellToken.Input); + // Tapping the padding focuses the editor too. + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => _editor.Focus(); + _border.GestureRecognizers.Add(tap); + + Content = _border; + UpdateSend(); + } + + private bool CanSubmit => IsEnabled && !IsLoading && !string.IsNullOrWhiteSpace(Text); + + // Sends the text as the button does. Does nothing while loading or when the box is empty. + public void Submit() + { + if (!CanSubmit) return; + var text = Text.Trim(); + Text = string.Empty; + Submitted?.Invoke(this, text); + } + + public new void Focus() => _editor.Focus(); + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName && _send != null) + { + Opacity = IsEnabled ? 1.0 : 0.5; + UpdateSend(); + } + } + + private void UpdateSend() + { + _send.IsLoading = IsLoading; + _send.IsEnabled = CanSubmit || IsLoading; + } +} diff --git a/examples/MAUI.Demo/Components/UI/Form.cs b/examples/MAUI.Demo/Components/UI/Form.cs new file mode 100644 index 0000000..da07eb3 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Form.cs @@ -0,0 +1,158 @@ +namespace MAUI.Demo.Components.UI; + +// Form — fields stacked with space-y-6. Each FormField is a label, a control, an optional +// description and an error message; validation is yours: set a field's Error and the form +// reports it. +// +// +// +// +// +// +// EmailField.Error = Email.Text.Contains('@') ? "" : "Enter a valid email."; +// if (SignUp.IsValid) ... +[ContentProperty(nameof(Fields))] +public partial class Form : ContentView +{ + public static readonly BindableProperty SpacingProperty = + BindableProperty.Create(nameof(Spacing), typeof(double), typeof(Form), 24d, + propertyChanged: (b, o, n) => ((Form)b)._stack.Spacing = (double)n); + + public double Spacing + { + get => (double)GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + // FormFields and anything else the form holds (buttons, separators). + public IList Fields => _stack.Children; + + // True when no field in the form has an Error. + public bool IsValid => FormFields().All(f => string.IsNullOrEmpty(f.Error)); + + // The first field with an Error, top to bottom. + public FormField? FirstInvalid => FormFields().FirstOrDefault(f => !string.IsNullOrEmpty(f.Error)); + + private readonly VerticalStackLayout _stack; + + public Form() + { + _stack = new VerticalStackLayout { Spacing = 24 }; + Content = _stack; + } + + // Every FormField in the form, however deeply it is nested. + public IEnumerable FormFields() => Walk(_stack); + + public void ClearErrors() + { + foreach (var field in FormFields()) field.Error = string.Empty; + } + + private static IEnumerable Walk(IVisualTreeElement parent) + { + foreach (var child in parent.GetVisualChildren()) + { + if (child is FormField field) yield return field; + else + foreach (var nested in Walk(child)) yield return nested; + } + } +} + +// One field of a Form — space-y-2: a text-sm font-medium label, the control, a muted +// description and, when Error is set, the error in text-destructive (the label turns red too). +[ContentProperty(nameof(Control))] +public partial class FormField : ContentView +{ + public static readonly BindableProperty LabelProperty = + BindableProperty.Create(nameof(Label), typeof(string), typeof(FormField), string.Empty, + propertyChanged: (b, o, n) => ((FormField)b).Update()); + + public static readonly BindableProperty DescriptionProperty = + BindableProperty.Create(nameof(Description), typeof(string), typeof(FormField), string.Empty, + propertyChanged: (b, o, n) => ((FormField)b).Update()); + + // The validation message; empty when the field is valid. + public static readonly BindableProperty ErrorProperty = + BindableProperty.Create(nameof(Error), typeof(string), typeof(FormField), string.Empty, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((FormField)b).Update()); + + // Adds a red asterisk after the label. + public static readonly BindableProperty IsRequiredProperty = + BindableProperty.Create(nameof(IsRequired), typeof(bool), typeof(FormField), false, + propertyChanged: (b, o, n) => ((FormField)b).Update()); + + public static readonly BindableProperty ControlProperty = + BindableProperty.Create(nameof(Control), typeof(View), typeof(FormField), null, + propertyChanged: (b, o, n) => ((FormField)b)._control.Content = n as View); + + public string Label + { + get => (string)GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public string Error + { + get => (string)GetValue(ErrorProperty); + set => SetValue(ErrorProperty, value); + } + + public bool IsRequired + { + get => (bool)GetValue(IsRequiredProperty); + set => SetValue(IsRequiredProperty, value); + } + + public View? Control + { + get => (View?)GetValue(ControlProperty); + set => SetValue(ControlProperty, value); + } + + public bool HasError => !string.IsNullOrEmpty(Error); + + private readonly Microsoft.Maui.Controls.Label _label; + private readonly Microsoft.Maui.Controls.Label _required; + private readonly HorizontalStackLayout _labelRow; + private readonly ContentView _control; + private readonly Microsoft.Maui.Controls.Label _description; + private readonly Microsoft.Maui.Controls.Label _error; + + public FormField() + { + _label = new Microsoft.Maui.Controls.Label { FontSize = 14, FontAttributes = FontAttributes.Bold }; + _required = new Microsoft.Maui.Controls.Label { Text = "*", FontSize = 14, FontAttributes = FontAttributes.Bold, IsVisible = false }; + _required.Token(Microsoft.Maui.Controls.Label.TextColorProperty, ShellToken.Destructive); + _labelRow = new HorizontalStackLayout { Spacing = 4, Children = { _label, _required } }; + _control = new ContentView(); + _description = new Microsoft.Maui.Controls.Label { FontSize = 14, IsVisible = false }; + _description.Token(Microsoft.Maui.Controls.Label.TextColorProperty, ShellToken.MutedForeground); + _error = new Microsoft.Maui.Controls.Label { FontSize = 14, FontAttributes = FontAttributes.Bold, IsVisible = false }; + _error.Token(Microsoft.Maui.Controls.Label.TextColorProperty, ShellToken.Destructive); + + Content = new VerticalStackLayout { Spacing = 8, Children = { _labelRow, _control, _description, _error } }; + Update(); + } + + private void Update() + { + _label.Text = Label ?? string.Empty; + _label.Token(Microsoft.Maui.Controls.Label.TextColorProperty, HasError ? ShellToken.Destructive : ShellToken.Foreground); + _required.IsVisible = IsRequired; + _labelRow.IsVisible = !string.IsNullOrEmpty(Label); + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + _error.Text = Error ?? string.Empty; + _error.IsVisible = HasError; + // Screen readers announce the message when it appears. + SemanticProperties.SetHint(this, Error ?? string.Empty); + } +} diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 8a29d83..6d854f0 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -262,6 +262,21 @@ + +