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