diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 6da1126..788600b 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -65,16 +65,10 @@ your background so the splash-to-app transition doesn't flash the template's pur --- -## Avalonia (in progress) +## Avalonia -Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `checkbox`, -`switch`, `card` and its parts, `separator`, `badge`, `progress`, `alert`), P1 (`dialog`, -`drawer`, `sheet`, `dropdown`, `popover` and their parts), P2 (`textarea`, `select`, `slider`, -`radio-group`, `calendar`, `date-picker`, `time-picker`), P3 (`collapsible`, `accordion`, -`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`), P4 (`spinner`, `tooltip`, -`toast`, `alert-dialog`, `hover-card` and its parts), P5 (`avatar`, `toggle`, `callout`, -`table`, `empty-state`, `pagination`) and P6 (`context-menu`, `carousel`, `stepper`). Not yet -published to NuGet. In an Avalonia project, `shellui-native list` shows only these. +Every component on this page has an Avalonia version, installed by the same CLI commands. The +Avalonia templates are not on NuGet yet: they ship with the next release. Overlays use Avalonia's `Popup` in the window's overlay layer: the content stays in the logical tree (theme, DataContext and `FindParentOfType` work as declared), dropdowns and popovers get @@ -88,6 +82,10 @@ Setup after `shellui-native init`: call `Components.UI.ShellTheme.EnsureInitiali `xmlns:ui="using:YourApp.Components.UI"` to your XAML. Switch theme with `ShellTheme.SetTheme(ThemeVariant.Dark)` or `ShellTheme.ToggleTheme()`. +Some names match Avalonia's own controls (`Button`, `Calendar`, `Carousel`, `ContextMenu`, +`TreeView`, …). In XAML the `ui:` prefix picks the ShellUI one. In C# that imports both +namespaces, qualify the name or add an alias: `using UI = YourApp.Components.UI;`. + Same names, properties and variants as MAUI, except: | | MAUI | Avalonia | @@ -114,6 +112,17 @@ 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) | +| ToggleGroup | Items toggle on tap | Items are also tab stops (Enter / Space); children are collected in `Items` | +| NumberInput | Numeric keyboard | Up / Down step the value while the field has focus | +| TagInput | Enter, comma or semicolon adds a tag | Backspace in the empty field also removes the last tag | +| InputOtp | Hidden entry with the numeric keyboard | Invisible text box over the slots; `Focus()` focuses it | +| Combobox / MultiSelect | Type to filter, Enter takes the first match | Up / Down move a highlight and Enter takes it; the search field has focus as soon as the panel opens; `Value` is `string?` | +| TreeView | Rows are tab stops | Also Right to expand and Left to collapse | +| TimelineItem content | One `Body` view | One child control, also reachable as `Body` | +| CopyButton | `Clipboard.Default` | The window's clipboard; does nothing until the button is in a window | +| LinkCard `Url` | `Launcher.Default` | The window's `Launcher` | +| AspectRatio | `ContentView` | A `Border`, so `CornerRadius` rounds the content | +| WrapLayout | Custom layout with `Padding` | A `Panel` (no `Padding`); Avalonia's own `WrapPanel` covers the plain case, `LastChildFill` is what this adds | --- diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index 86b4bfd..44070df 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-07** (first prerelease, `0.1.0-alpha.1`). -**Status at a glance:** P0–P5 ✅ done · P6 partly done (resizable, navbar, sidebar open) · P7 partly done · shipped as `0.1.0-alpha.1` (58 families, 89 CLI targets) · **Avalonia (Phase 2) ⏭️ next**. +**Status at a glance:** P0–P5 ✅ done · P6 partly done (resizable, navbar, sidebar open) · P7 partly done · shipped as `0.1.0-alpha.1` (58 families, 89 CLI targets) · **Avalonia (Phase 2): every component ported, not released yet**. --- diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index 4d3a322..5503aea 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -35,9 +35,8 @@ main ← Phase 1a (2026-07-05), 1b (PR #2), 1c + 1d (PR #3), 1e (PR #4), brand | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P4 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P4 | -| `feat/avalonia-p5-data-display` | `main` | Pushed, PR next | Phase 2 P5: Avalonia data display | -| `feat/avalonia-p6-advanced` | `feat/avalonia-p5-data-display` | **Active** | Phase 2 P6: Avalonia context menu, carousel and stepper | +| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P6 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P6 | +| `feat/avalonia-p7-remaining` | `main` | **Active** | Phase 2: the 14 components that were still MAUI-only | --- @@ -435,7 +434,7 @@ After merge: tag `v0.1.0-alpha.1` on `main` and push the tag. --- -## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p6-advanced` now) +## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p7-remaining` now) Cross-desktop (Windows + macOS + Linux) from one XAML codebase. @@ -490,6 +489,13 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. animation frames. Each installs alone into a fresh Avalonia app and builds with 0 warnings; checked in the demo in light and dark with real pointer and key input. resizable, navbar and sidebar wait for their MAUI versions +- [x] The rest (P7 and the P5 leftovers): toggle-group, number-input, tag-input, input-otp, + combobox, multi-select, kbd, copy-button, stat-card, link-card, tree-view, timeline, + aspect-ratio, wrap-layout. Every registry entry now has Avalonia content. Combobox and + multi-select float their panel with `ShellAnchoredPopup` and add an Up / Down highlight; + wrap-layout is a `Panel` with the MAUI layout's `LastChildFill`. Each installs alone into a + fresh Avalonia app and builds with 0 warnings; checked in the demo in light and dark with + pointer and key input - [ ] 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/docs/README.md b/docs/README.md index 5df1f32..f622a80 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,7 +54,7 @@ shellui-native list | Platform | Status | .NET Version | |----------|--------|--------------| | .NET MAUI | Available (`0.1.0-alpha.1`) — Android, iOS, Mac Catalyst, Windows | .NET 10.0 | -| Avalonia UI | In progress (Phase 2): theme, icons, P0–P5 components and P6's context menu, carousel and stepper; not on NuGet yet | .NET 10.0 (Avalonia 12) | +| Avalonia UI | Every component ported (Phase 2); not on NuGet yet, ships with the next release | .NET 10.0 (Avalonia 12) | | WinUI 3 | Conditional (Phase 3) | .NET 10.0 | WPF is intentionally not on this list — it's recognized for project detection only, not an diff --git a/examples/Avalonia.Demo/Components/UI/AspectRatio.cs b/examples/Avalonia.Demo/Components/UI/AspectRatio.cs new file mode 100644 index 0000000..e0d9c7d --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AspectRatio.cs @@ -0,0 +1,42 @@ +using Avalonia; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Keeps its content at a fixed width / height ratio: the height follows the width. +// +// +// +public class AspectRatio : Border +{ + // Width divided by height. 16:9 is 1.7778, 4:3 is 1.3333, a square is 1. + public static readonly StyledProperty RatioProperty = + AvaloniaProperty.Register(nameof(Ratio), 16d / 9d); + + static AspectRatio() + { + AffectsMeasure(RatioProperty); + } + + public double Ratio + { + get => GetValue(RatioProperty); + set => SetValue(RatioProperty, value); + } + + public AspectRatio() + { + // With a CornerRadius the content is clipped to the rounded shape. + ClipToBounds = true; + } + + protected override Size MeasureOverride(Size availableSize) + { + if (Ratio <= 0) return base.MeasureOverride(availableSize); + // Without a width to fill (e.g. in a horizontal stack) it takes the content's width. + var width = double.IsInfinity(availableSize.Width) ? base.MeasureOverride(availableSize).Width : availableSize.Width; + var size = new Size(width, width / Ratio); + base.MeasureOverride(size); + return size; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Combobox.cs b/examples/Avalonia.Demo/Components/UI/Combobox.cs new file mode 100644 index 0000000..ee93735 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Combobox.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace AvaloniaDemo.Components.UI; + +// Combobox — a Select with a search field: h-10 trigger, and a floating panel with a filter +// input above the option list (check on the selected option, "no results" when nothing matches). +// Type to filter, Up / Down to move, Enter to pick, Escape to close. +// Usage: then set +// ItemsSource in code or by binding. +public class Combobox : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty?> ItemsSourceProperty = + AvaloniaProperty.Register?>(nameof(ItemsSource)); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Select..."); + + public static readonly StyledProperty SearchPlaceholderProperty = + AvaloniaProperty.Register(nameof(SearchPlaceholder), "Search..."); + + public static readonly StyledProperty EmptyTextProperty = + AvaloniaProperty.Register(nameof(EmptyText), "No results found."); + + private const double ItemHeight = 32; + private const double MaxListHeight = 240; + + private readonly Border _trigger; + private readonly TextBlock _value; + private readonly Border _panel; + private readonly TextBox _search; + private readonly StackPanel _list = new(); + private readonly Panel _listArea; + private readonly TextBlock _empty; + private readonly List<(string Text, Border Row, Icon Check)> _rows = new(); + private readonly ShellAnchoredPopup _popup; + private int _active = -1; + + static Combobox() + { + ValueProperty.Changed.AddClassHandler((c, _) => + { + c.UpdateTrigger(); + c.UpdateChecks(); + c.ValueChanged?.Invoke(c, c.Value ?? string.Empty); + }); + ItemsSourceProperty.Changed.AddClassHandler((c, _) => c.RebuildItems()); + PlaceholderProperty.Changed.AddClassHandler((c, _) => c.UpdateTrigger()); + SearchPlaceholderProperty.Changed.AddClassHandler((c, e) => c._search.PlaceholderText = (string?)e.NewValue); + EmptyTextProperty.Changed.AddClassHandler((c, e) => c._empty.Text = (string?)e.NewValue); + IsEnabledProperty.Changed.AddClassHandler((c, _) => c.Opacity = c.IsEnabled ? 1.0 : 0.5); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IList? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string? SearchPlaceholder + { + get => GetValue(SearchPlaceholderProperty); + set => SetValue(SearchPlaceholderProperty, value); + } + + public string? EmptyText + { + get => GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public bool IsOpen => _popup.IsOpen; + + public event EventHandler? ValueChanged; + + public Combobox() + { + _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + var chevrons = new Icon { Kind = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground }; + Grid.SetColumn(chevrons, 1); + _trigger = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8, Children = { _value, chevrons } }, + Height = 40, + Padding = new Thickness(12, 0), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + _trigger.Token(BorderBrushProperty, ShellToken.Input); + _trigger.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + SetOpen(!IsOpen); + e.Handled = true; + }; + + _search = new TextBox { FontSize = 14, PlaceholderText = SearchPlaceholder, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center }; + ShellPlatform.StripNativeChrome(_search); + _search.TextChanged += (_, _) => Filter(); + _search.KeyDown += OnSearchKeyDown; + Grid.SetColumn(_search, 1); + var searchRow = new Border + { + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + ColumnSpacing = 8, + Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _search } + }, + Height = 40, + Padding = new Thickness(12, 0) + }; + + var divider = new Border { Height = 1 }; + divider.Token(BackgroundProperty, ShellToken.Border); + + _empty = new TextBlock { Text = EmptyText, FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering. + _listArea = new Panel { Margin = new Thickness(4), Children = { new ScrollViewer { Content = _list }, _empty } }; + + _panel = new Border + { + Child = new StackPanel { Children = { searchRow, divider, _listArea } }, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + BoxShadow = ShellPopups.PanelShadow() + }; + _panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + + _popup = new ShellAnchoredPopup(_trigger); + _popup.Popup.Child = _panel; + _popup.Closed += (_, _) => + { + _trigger.Token(BorderBrushProperty, ShellToken.Input); + // Focus goes back to the trigger instead of being lost with the panel. + if (_search.IsFocused) Focus(); + }; + + Child = new Panel { Children = { _trigger, _popup.Popup } }; + ShellFocus.Ring(this, _trigger); + RebuildItems(); + } + + public void SetOpen(bool open) + { + if (open == _popup.IsOpen) return; + if (!open) + { + _popup.Close(); + return; + } + _search.Text = string.Empty; + Filter(); + // The panel matches the trigger's width. + _panel.Width = _trigger.Bounds.Width; + _trigger.Token(BorderBrushProperty, ShellToken.Ring); + _popup.Open(); + Dispatcher.UIThread.Post(() => + { + if (IsOpen) _search.Focus(); + }, DispatcherPriority.Loaded); + } + + public void Close() => SetOpen(false); + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + // Only keys pressed on the closed trigger: the search field handles its own. + if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return; + SetOpen(true); + e.Handled = true; + } + + private void OnSearchKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + if (_active >= 0) Pick(_rows[_active].Text); + } + else if (e.Key == Key.Down) MoveActive(1); + else if (e.Key == Key.Up) MoveActive(-1); + else return; + e.Handled = true; + } + + private void Pick(string text) + { + Value = text; + SetOpen(false); + } + + private void UpdateTrigger() + { + var hasValue = !string.IsNullOrEmpty(Value); + _value.Text = hasValue ? Value : Placeholder ?? string.Empty; + _value.Token(TextBlock.ForegroundProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground); + } + + private void RebuildItems() + { + _list.Children.Clear(); + _rows.Clear(); + _active = -1; + var items = ItemsSource; + _listArea.Height = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight); + if (items != null) + { + foreach (var text in items) + _list.Children.Add(CreateItem(text, _rows.Count)); + } + UpdateTrigger(); + UpdateChecks(); + Filter(); + } + + private Control CreateItem(string text, int index) + { + var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground); + var check = new Icon { Kind = IconName.Check, Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground }; + Grid.SetColumn(check, 1); + + var item = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8, Children = { label, check } }, + Height = ItemHeight, + Padding = new Thickness(8, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + // The pointer and the arrow keys move the same highlight. + item.PointerMoved += (_, _) => SetActive(index); + item.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + Pick(text); + e.Handled = true; + }; + + _rows.Add((text, item, check)); + return item; + } + + private void UpdateChecks() + { + foreach (var (text, _, check) in _rows) + check.IsVisible = text == Value; + } + + private void Filter() + { + var query = _search.Text?.Trim() ?? string.Empty; + var first = -1; + for (var i = 0; i < _rows.Count; i++) + { + var (text, row, _) = _rows[i]; + row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase); + if (row.IsVisible && first < 0) first = i; + } + _empty.IsVisible = first < 0; + SetActive(first); + } + + private void MoveActive(int direction) + { + for (var i = _active + direction; i >= 0 && i < _rows.Count; i += direction) + { + if (!_rows[i].Row.IsVisible) continue; + SetActive(i); + _rows[i].Row.BringIntoView(); + return; + } + } + + private void SetActive(int index) + { + if (index == _active) return; + _active = index; + for (var i = 0; i < _rows.Count; i++) + { + var row = _rows[i].Row; + if (i == index) row.Token(BackgroundProperty, ShellToken.Accent); + else + { + row.ClearToken(BackgroundProperty); + row.Background = Brushes.Transparent; + } + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/CopyButton.cs b/examples/Avalonia.Demo/Components/UI/CopyButton.cs new file mode 100644 index 0000000..bf9c9b2 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/CopyButton.cs @@ -0,0 +1,146 @@ +using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Copies Text to the clipboard — a ghost icon button (h-9 w-9 rounded-md, hover:bg-accent) whose +// icon turns into a check for two seconds after copying. Label adds text next to the icon. +// Usage: +public class CopyButton : Border, IShellFocusable +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private bool _copied; + private int _version; + + static CopyButton() + { + TextProperty.Changed.AddClassHandler((b, _) => b.Update()); + LabelProperty.Changed.AddClassHandler((b, _) => b.Update()); + IsEnabledProperty.Changed.AddClassHandler((b, _) => b.Update()); + } + + // The text placed on the clipboard. + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Label + { + get => GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public event EventHandler? Copied; + + public CopyButton() + { + _icon = new Icon { Kind = IconName.Copy, Size = 16, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { _icon, _label } + }; + Height = 36; + MinWidth = 36; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + HorizontalAlignment = HorizontalAlignment.Left; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return; + _ = CopyAsync(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + _ = CopyAsync(); + e.Handled = true; + } + + public async Task CopyAsync() + { + if (!IsEffectivelyEnabled || string.IsNullOrEmpty(Text)) return; + if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return; + try + { + await clipboard.SetTextAsync(Text); + } + catch (Exception) + { + // No clipboard access: leave the icon as it is. + return; + } + + var version = ++_version; + _copied = true; + Update(); + Copied?.Invoke(this, EventArgs.Empty); + await Task.Delay(2000); + if (version != _version) return; + _copied = false; + Update(); + } + + private void Update() + { + var hasLabel = !string.IsNullOrEmpty(Label); + _label.Text = Label ?? string.Empty; + _label.IsVisible = hasLabel; + Padding = new Thickness(hasLabel ? 12 : 0, 0); + + var hovered = IsPointerOver && IsEffectivelyEnabled; + _icon.Kind = _copied ? IconName.Check : IconName.Copy; + _icon.Token = _copied ? ShellToken.Success : hovered ? ShellToken.AccentForeground : ShellToken.Foreground; + _label.Token(TextBlock.ForegroundProperty, hovered ? ShellToken.AccentForeground : ShellToken.Foreground); + + if (hovered) this.Token(BackgroundProperty, ShellToken.Accent); + else + { + // Transparent, not null, so the whole button stays hit-testable. + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + Opacity = IsEnabled && !string.IsNullOrEmpty(Text) ? 1.0 : 0.5; + AutomationProperties.SetName(this, _copied ? "Copied" : "Copy to clipboard"); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/InputOtp.cs b/examples/Avalonia.Demo/Components/UI/InputOtp.cs new file mode 100644 index 0000000..8d8c982 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/InputOtp.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Threading; + +namespace AvaloniaDemo.Components.UI; + +// One-time-code input — a row of 40px slots (rounded-md border border-input) over one invisible +// text box, so typing, paste and input methods all work. The active slot shows the ring color +// and a blinking caret. +// Usage: +public class InputOtp : Border +{ + public static readonly StyledProperty LengthProperty = + AvaloniaProperty.Register(nameof(Length), 6); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), string.Empty, defaultBindingMode: BindingMode.TwoWay); + + // Digits only. False allows letters too. + public static readonly StyledProperty IsNumericProperty = + AvaloniaProperty.Register(nameof(IsNumeric), true); + + public static readonly StyledProperty HasErrorProperty = + AvaloniaProperty.Register(nameof(HasError)); + + private const double SlotSize = 40; + + private readonly TextBox _textBox; + private readonly StackPanel _slots = new() { Orientation = Orientation.Horizontal, Spacing = 8, IsHitTestVisible = false }; + private readonly List<(Border Box, TextBlock Text, Border Caret)> _items = new(); + private DispatcherTimer? _blink; + private bool _focused; + + static InputOtp() + { + LengthProperty.Changed.AddClassHandler((o, _) => o.BuildSlots()); + ValueProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged()); + IsNumericProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged()); + HasErrorProperty.Changed.AddClassHandler((o, _) => o.UpdateSlots()); + IsEnabledProperty.Changed.AddClassHandler((o, _) => o.Opacity = o.IsEnabled ? 1.0 : 0.5); + } + + public int Length + { + get => GetValue(LengthProperty); + set => SetValue(LengthProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public bool IsNumeric + { + get => GetValue(IsNumericProperty); + set => SetValue(IsNumericProperty, value); + } + + public bool HasError + { + get => GetValue(HasErrorProperty); + set => SetValue(HasErrorProperty, value); + } + + public event EventHandler? ValueChanged; + // Raised when every slot is filled. + public event EventHandler? Completed; + + public InputOtp() + { + // The real input: stretched over the slots and invisible, so a click anywhere focuses it. + _textBox = new TextBox { Opacity = 0, Cursor = new Cursor(StandardCursorType.Ibeam) }; + ShellPlatform.StripNativeChrome(_textBox); + AutomationProperties.SetName(_textBox, "Verification code"); + _textBox.TextChanged += (_, _) => + { + // Characters that don't belong (or don't fit) never stay in the hidden text. + var value = Sanitize(_textBox.Text); + if (_textBox.Text != value) + { + _textBox.Text = value; + _textBox.CaretIndex = value.Length; + } + Value = value; + }; + _textBox.GotFocus += (_, _) => + { + _focused = true; + _textBox.CaretIndex = _textBox.Text?.Length ?? 0; + UpdateSlots(); + }; + _textBox.LostFocus += (_, _) => + { + _focused = false; + UpdateSlots(); + }; + + Child = new Panel { Children = { _slots, _textBox } }; + HorizontalAlignment = HorizontalAlignment.Left; + BuildSlots(); + } + + public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) + => _textBox.Focus(method, keyModifiers); + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + StopBlink(); + } + + private int SlotCount => Math.Max(1, Length); + + private string Sanitize(string? text) + { + var filtered = new string((text ?? string.Empty).Where(c => IsNumeric ? char.IsDigit(c) : char.IsLetterOrDigit(c)).ToArray()); + return filtered.Length > SlotCount ? filtered[..SlotCount] : filtered; + } + + private void BuildSlots() + { + _slots.Children.Clear(); + _items.Clear(); + for (var i = 0; i < SlotCount; i++) + { + var text = new TextBlock { FontSize = 16, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + var caret = new Border { Width = 1.5, Height = 18, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + caret.Token(BackgroundProperty, ShellToken.Foreground); + var box = new Border + { + Child = new Panel { Children = { text, caret } }, + Width = SlotSize, + Height = SlotSize, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd) + }; + _items.Add((box, text, caret)); + _slots.Children.Add(box); + } + OnValueChanged(); + } + + private void OnValueChanged() + { + var value = Sanitize(Value); + if (value != Value) + { + Value = value; + return; + } + if (_textBox.Text != value) + { + _textBox.Text = value; + _textBox.CaretIndex = value.Length; + } + UpdateSlots(); + ValueChanged?.Invoke(this, value); + if (value.Length == Length && Length > 0) Completed?.Invoke(this, value); + } + + private void UpdateSlots() + { + var value = Value ?? string.Empty; + // The caret sits in the next empty slot (the last one when the code is complete). + var active = _focused ? Math.Min(value.Length, _items.Count - 1) : -1; + for (var i = 0; i < _items.Count; i++) + { + var (box, text, caret) = _items[i]; + text.Text = i < value.Length ? value[i].ToString() : string.Empty; + var isActive = i == active; + box.Token(BorderBrushProperty, HasError ? ShellToken.Destructive : isActive ? ShellToken.Ring : ShellToken.Input); + caret.IsVisible = isActive && i >= value.Length; + caret.Opacity = 1; + } + + StopBlink(); + if (active < 0 || active < value.Length) return; + var blinking = _items[active].Caret; + _blink = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Normal, + (_, _) => blinking.Opacity = blinking.Opacity > 0.5 ? 0 : 1); + _blink.Start(); + } + + private void StopBlink() + { + _blink?.Stop(); + _blink = null; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Kbd.cs b/examples/Avalonia.Demo/Components/UI/Kbd.cs new file mode 100644 index 0000000..4054dd9 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Kbd.cs @@ -0,0 +1,49 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Keyboard key — h-5 rounded border bg-muted px-1.5 text-[11px] font-medium text-muted-foreground. +// Usage: +public class Kbd : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + private readonly TextBlock _label; + + static Kbd() + { + TextProperty.Changed.AddClassHandler((k, e) => k._label.Text = (string?)e.NewValue ?? string.Empty); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public Kbd() + { + _label = new TextBlock + { + FontSize = 11, + FontWeight = FontWeight.Medium, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + Child = _label; + Height = 22; + MinWidth = 22; + Padding = new Thickness(6, 0); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusSm); + HorizontalAlignment = HorizontalAlignment.Left; + VerticalAlignment = VerticalAlignment.Center; + this.Token(BackgroundProperty, ShellToken.Muted).Token(BorderBrushProperty, ShellToken.Border); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/LinkCard.cs b/examples/Avalonia.Demo/Components/UI/LinkCard.cs new file mode 100644 index 0000000..b36351f --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/LinkCard.cs @@ -0,0 +1,146 @@ +using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Clickable card — rounded-lg border bg-card p-4, hover:bg-accent: an optional icon, a title, a +// muted description and an arrow. A click (or Enter / Space) raises Clicked and opens Url when +// one is set. +// Usage: +public class LinkCard : Border, IShellFocusable +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + // Opened in the system browser (or the app registered for the link) when the card is clicked. + public static readonly StyledProperty UrlProperty = + AvaloniaProperty.Register(nameof(Url)); + + private readonly Icon _icon; + private readonly TextBlock _title; + private readonly TextBlock _description; + + static LinkCard() + { + TitleProperty.Changed.AddClassHandler((c, _) => c.Update()); + DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update()); + IconProperty.Changed.AddClassHandler((c, _) => c.Update()); + IsEnabledProperty.Changed.AddClassHandler((c, _) => c.Opacity = c.IsEnabled ? 1.0 : 0.5); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string? Url + { + get => GetValue(UrlProperty); + set => SetValue(UrlProperty, value); + } + + public event EventHandler? Clicked; + + public LinkCard() + { + _icon = new Icon { Size = 20, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 1, 0, 0) }; + _title = new TextBlock { FontSize = 15, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); + _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap }; + _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + var text = new StackPanel { Spacing = 4, Children = { _title, _description } }; + Grid.SetColumn(text, 1); + var arrow = new Icon { Kind = IconName.ArrowUpRight, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) }; + Grid.SetColumn(arrow, 2); + + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 12, Children = { _icon, text, arrow } }; + Padding = new Thickness(16); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + Cursor = new Cursor(StandardCursorType.Hand); + this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + if (IsEffectivelyEnabled) this.Token(BackgroundProperty, ShellToken.Accent); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + this.Token(BackgroundProperty, ShellToken.Card); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + Press(); + e.Handled = true; + } + + // Clicks in code, as a pointer click or Enter/Space would. + public async void Press() + { + if (!IsEffectivelyEnabled) return; + Clicked?.Invoke(this, EventArgs.Empty); + if (string.IsNullOrWhiteSpace(Url) || !Uri.TryCreate(Url, UriKind.Absolute, out var uri)) return; + if (TopLevel.GetTopLevel(this) is not { } top) return; + try + { + await top.Launcher.LaunchUriAsync(uri); + } + catch (Exception) + { + // Nothing on the device can open this link. + } + } + + private void Update() + { + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + AutomationProperties.SetName(this, Title ?? string.Empty); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/MultiSelect.cs b/examples/Avalonia.Demo/Components/UI/MultiSelect.cs new file mode 100644 index 0000000..26da092 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/MultiSelect.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace AvaloniaDemo.Components.UI; + +// Multi-select — a min-h-10 trigger that shows the picked options as removable chips, and a +// floating panel with a search field and a checked list. Picking toggles an option and leaves +// the panel open; click outside or press Escape to close it. Type to filter, Up / Down to move, +// Enter to toggle. +// Usage: then +// set ItemsSource in code or by binding. +public class MultiSelect : Border +{ + public static readonly StyledProperty?> ItemsSourceProperty = + AvaloniaProperty.Register?>(nameof(ItemsSource)); + + public static readonly StyledProperty?> ValuesProperty = + AvaloniaProperty.Register?>(nameof(Values), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Select..."); + + public static readonly StyledProperty SearchPlaceholderProperty = + AvaloniaProperty.Register(nameof(SearchPlaceholder), "Search..."); + + public static readonly StyledProperty EmptyTextProperty = + AvaloniaProperty.Register(nameof(EmptyText), "No results found."); + + private const double ItemHeight = 32; + private const double MaxListHeight = 240; + + private readonly Border _trigger; + private readonly WrapLayout _chips; + private readonly TextBlock _placeholder; + private readonly Border _panel; + private readonly TextBox _search; + private readonly StackPanel _list = new(); + private readonly Panel _listArea; + private readonly TextBlock _empty; + private readonly List<(string Text, Border Row, Icon Check)> _rows = new(); + private readonly ShellAnchoredPopup _popup; + private int _active = -1; + + static MultiSelect() + { + ItemsSourceProperty.Changed.AddClassHandler((m, _) => m.RebuildItems()); + ValuesProperty.Changed.AddClassHandler((m, e) => m.OnValuesReplaced(e.OldValue, e.NewValue)); + PlaceholderProperty.Changed.AddClassHandler((m, e) => m._placeholder.Text = (string?)e.NewValue); + SearchPlaceholderProperty.Changed.AddClassHandler((m, e) => m._search.PlaceholderText = (string?)e.NewValue); + EmptyTextProperty.Changed.AddClassHandler((m, e) => m._empty.Text = (string?)e.NewValue); + IsEnabledProperty.Changed.AddClassHandler((m, _) => m.Opacity = m.IsEnabled ? 1.0 : 0.5); + } + + public IList? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public IList? Values + { + get => GetValue(ValuesProperty); + set => SetValue(ValuesProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string? SearchPlaceholder + { + get => GetValue(SearchPlaceholderProperty); + set => SetValue(SearchPlaceholderProperty, value); + } + + public string? EmptyText + { + get => GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public bool IsOpen => _popup.IsOpen; + + public event EventHandler>? ValuesChanged; + + public MultiSelect() + { + _placeholder = new TextBlock { Text = Placeholder, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0) }; + _placeholder.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _chips = new WrapLayout { Spacing = 4, LineSpacing = 4, VerticalAlignment = VerticalAlignment.Center }; + var chevrons = new Icon { Kind = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(chevrons, 1); + + _trigger = new Border + { + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + ColumnSpacing = 8, + Children = { new Panel { Children = { _placeholder, _chips } }, chevrons } + }, + MinHeight = 40, + Padding = new Thickness(8, 6, 12, 6), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + _trigger.Token(BorderBrushProperty, ShellToken.Input); + _trigger.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + SetOpen(!IsOpen); + e.Handled = true; + }; + + _search = new TextBox { FontSize = 14, PlaceholderText = SearchPlaceholder, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center }; + ShellPlatform.StripNativeChrome(_search); + _search.TextChanged += (_, _) => Filter(); + _search.KeyDown += OnSearchKeyDown; + Grid.SetColumn(_search, 1); + var searchRow = new Border + { + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + ColumnSpacing = 8, + Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _search } + }, + Height = 40, + Padding = new Thickness(12, 0) + }; + + var divider = new Border { Height = 1 }; + divider.Token(BackgroundProperty, ShellToken.Border); + + _empty = new TextBlock { Text = EmptyText, FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering. + _listArea = new Panel { Margin = new Thickness(4), Children = { new ScrollViewer { Content = _list }, _empty } }; + + _panel = new Border + { + Child = new StackPanel { Children = { searchRow, divider, _listArea } }, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + BoxShadow = ShellPopups.PanelShadow() + }; + _panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + + _popup = new ShellAnchoredPopup(_trigger); + _popup.Popup.Child = _panel; + _popup.Closed += (_, _) => + { + _trigger.Token(BorderBrushProperty, ShellToken.Input); + // Focus goes back to the trigger instead of being lost with the panel. + if (_search.IsFocused) Focus(); + }; + + Child = new Panel { Children = { _trigger, _popup.Popup } }; + ShellFocus.Ring(this, _trigger); + RebuildItems(); + RebuildChips(); + } + + private IReadOnlyList Current => (IReadOnlyList?)Values?.ToList() ?? Array.Empty(); + + public void SetOpen(bool open) + { + if (open == _popup.IsOpen) return; + if (!open) + { + _popup.Close(); + return; + } + _search.Text = string.Empty; + Filter(); + // The panel matches the trigger's width. + _panel.Width = _trigger.Bounds.Width; + _trigger.Token(BorderBrushProperty, ShellToken.Ring); + _popup.Open(); + Dispatcher.UIThread.Post(() => + { + if (IsOpen) _search.Focus(); + }, DispatcherPriority.Loaded); + } + + public void Close() => SetOpen(false); + + // Adds the option when it isn't picked, removes it when it is. + public void Toggle(string text) + { + // A new list each time, so a two-way binding sees the change. + var next = new List(Current); + if (!next.Remove(text)) next.Add(text); + Values = next; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + // Only keys pressed on the closed trigger: the search field handles its own. + if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return; + SetOpen(true); + e.Handled = true; + } + + private void OnSearchKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + if (_active >= 0) Toggle(_rows[_active].Text); + } + else if (e.Key == Key.Down) MoveActive(1); + else if (e.Key == Key.Up) MoveActive(-1); + else return; + e.Handled = true; + } + + private void OnValuesReplaced(object? oldValue, object? newValue) + { + if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated; + if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated; + OnValuesMutated(this, null); + } + + private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) + { + RebuildChips(); + UpdateChecks(); + ValuesChanged?.Invoke(this, Current); + } + + private void RebuildChips() + { + _chips.Children.Clear(); + var current = Current; + foreach (var value in current) + _chips.Children.Add(CreateChip(value)); + _placeholder.IsVisible = current.Count == 0; + } + + private Control CreateChip(string value) + { + var label = new TextBlock { Text = value, FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.SecondaryForeground); + var close = new Icon { Kind = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + // 12px icon in a 20px click target. Its clicks stop here, so they don't toggle the panel. + var remove = new Border { Width = 20, Height = 20, Background = Brushes.Transparent, Child = close }; + AutomationProperties.SetName(remove, $"Remove {value}"); + remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive; + remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground; + remove.PointerPressed += (_, e) => e.Handled = true; + remove.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + if (IsEffectivelyEnabled) Toggle(value); + e.Handled = true; + }; + + var chip = new Border + { + Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, Children = { label, remove } }, + Padding = new Thickness(8, 0, 2, 0), + Height = 24, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd) + }; + chip.Token(BackgroundProperty, ShellToken.Secondary); + return chip; + } + + private void RebuildItems() + { + _list.Children.Clear(); + _rows.Clear(); + _active = -1; + var items = ItemsSource; + _listArea.Height = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight); + if (items != null) + { + foreach (var text in items) + _list.Children.Add(CreateItem(text, _rows.Count)); + } + UpdateChecks(); + Filter(); + } + + private Control CreateItem(string text, int index) + { + var check = new Icon { Kind = IconName.Check, Size = 16, Opacity = 0, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Center }; + var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground); + Grid.SetColumn(label, 1); + + var item = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*"), ColumnSpacing = 8, Children = { check, label } }, + Height = ItemHeight, + Padding = new Thickness(8, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + // The pointer and the arrow keys move the same highlight. + item.PointerMoved += (_, _) => SetActive(index); + item.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + Toggle(text); + e.Handled = true; + }; + + _rows.Add((text, item, check)); + return item; + } + + private void UpdateChecks() + { + var current = Current; + // The check keeps its column when hidden, so option labels stay aligned. + foreach (var (text, _, check) in _rows) + check.Opacity = current.Contains(text) ? 1 : 0; + } + + private void Filter() + { + var query = _search.Text?.Trim() ?? string.Empty; + var first = -1; + for (var i = 0; i < _rows.Count; i++) + { + var (text, row, _) = _rows[i]; + row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase); + if (row.IsVisible && first < 0) first = i; + } + _empty.IsVisible = first < 0; + SetActive(first); + } + + private void MoveActive(int direction) + { + for (var i = _active + direction; i >= 0 && i < _rows.Count; i += direction) + { + if (!_rows[i].Row.IsVisible) continue; + SetActive(i); + _rows[i].Row.BringIntoView(); + return; + } + } + + private void SetActive(int index) + { + if (index == _active) return; + _active = index; + for (var i = 0; i < _rows.Count; i++) + { + var row = _rows[i].Row; + if (i == index) row.Token(BackgroundProperty, ShellToken.Accent); + else + { + row.ClearToken(BackgroundProperty); + row.Background = Brushes.Transparent; + } + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/NumberInput.cs b/examples/Avalonia.Demo/Components/UI/NumberInput.cs new file mode 100644 index 0000000..d0be2ca --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/NumberInput.cs @@ -0,0 +1,208 @@ +using System; +using System.Globalization; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Number field with − and + buttons — h-10 rounded-md border, the value centered between them. +// The value is clamped to Minimum / Maximum; the buttons dim at the limits. Up and Down step +// the value while the field has focus. +// Usage: +public class NumberInput : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay, + coerce: (o, value) => ((NumberInput)o).Clamp(value)); + + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum)); + + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum)); + + public static readonly StyledProperty StepProperty = + AvaloniaProperty.Register(nameof(Step), 1m); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder)); + + private readonly TextBox _textBox; + private readonly Border _decrease; + private readonly Border _increase; + + static NumberInput() + { + ValueProperty.Changed.AddClassHandler((n, _) => + { + n.UpdateText(); + n.UpdateButtons(); + n.ValueChanged?.Invoke(n, n.Value); + }); + MinimumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); + MaximumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); + PlaceholderProperty.Changed.AddClassHandler((n, e) => n._textBox.PlaceholderText = (string?)e.NewValue); + IsEnabledProperty.Changed.AddClassHandler((n, _) => n.Opacity = n.IsEnabled ? 1.0 : 0.5); + } + + public decimal? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public decimal? Minimum + { + get => GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + public decimal? Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public decimal Step + { + get => GetValue(StepProperty); + set => SetValue(StepProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public event EventHandler? ValueChanged; + + public NumberInput() + { + _textBox = new TextBox + { + FontSize = 14, + TextAlignment = TextAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + ShellPlatform.StripNativeChrome(_textBox); + _textBox.GotFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Ring); + _textBox.LostFocus += (_, _) => + { + this.Token(BorderBrushProperty, ShellToken.Input); + Commit(); + }; + _textBox.KeyDown += (_, e) => + { + if (e.Key == Key.Enter) Commit(); + else if (e.Key == Key.Up) StepBy(1); + else if (e.Key == Key.Down) StepBy(-1); + else return; + e.Handled = true; + }; + Grid.SetColumn(_textBox, 2); + + _decrease = StepButton(IconName.Minus, "Decrease", -1); + _increase = StepButton(IconName.Plus, "Increase", 1); + Grid.SetColumn(_increase, 4); + + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("40,1,*,1,40"), + Children = { _decrease, Divider(1), _textBox, Divider(3), _increase } + }; + Height = 40; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + // The step buttons' hover tint stays inside the rounded corners. + ClipToBounds = true; + this.Token(BorderBrushProperty, ShellToken.Input); + UpdateText(); + UpdateButtons(); + } + + private static Border Divider(int column) + { + var line = new Border(); + line.Token(BackgroundProperty, ShellToken.Input); + Grid.SetColumn(line, column); + return line; + } + + private Border StepButton(IconName icon, string name, int direction) + { + var glyph = new Icon { Kind = icon, Size = 16, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + var button = new Border { Child = glyph, Background = Brushes.Transparent, Cursor = new Cursor(StandardCursorType.Hand) }; + AutomationProperties.SetName(button, name); + button.PointerEntered += (_, _) => + { + if (button.Opacity < 0.9) return; + button.Token(BackgroundProperty, ShellToken.Muted); + glyph.Token = ShellToken.Foreground; + }; + button.PointerExited += (_, _) => + { + button.ClearToken(BackgroundProperty); + button.Background = Brushes.Transparent; + glyph.Token = ShellToken.MutedForeground; + }; + button.PointerPressed += (_, e) => e.Handled = true; + button.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + StepBy(direction); + e.Handled = true; + }; + return button; + } + + private void StepBy(int direction) + { + if (!IsEffectivelyEnabled) return; + Commit(); + Value = (Value ?? Minimum ?? 0) + direction * Step; + } + + private decimal? Clamp(decimal? value) + { + if (value is not { } number) return null; + if (Minimum is { } min && number < min) number = min; + if (Maximum is { } max && number > max) number = max; + return number; + } + + // A typed number is clamped and kept, empty clears the value, anything else is reverted. + private void Commit() + { + var text = _textBox.Text?.Trim(); + if (string.IsNullOrEmpty(text)) Value = null; + else if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var typed) || + decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out typed)) + Value = typed; + UpdateText(); + } + + private void OnLimitsChanged() + { + CoerceValue(ValueProperty); + UpdateButtons(); + } + + private void UpdateText() + { + var text = Value?.ToString(CultureInfo.CurrentCulture) ?? string.Empty; + if (_textBox.Text != text) _textBox.Text = text; + } + + private void UpdateButtons() + { + _decrease.Opacity = Value is { } v && Minimum is { } min && v <= min ? 0.4 : 1; + _increase.Opacity = Value is { } w && Maximum is { } max && w >= max ? 0.4 : 1; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/StatCard.cs b/examples/Avalonia.Demo/Components/UI/StatCard.cs new file mode 100644 index 0000000..9bacd99 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/StatCard.cs @@ -0,0 +1,145 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Dashboard metric — rounded-lg border bg-card p-5: a muted title with an optional icon, the +// value in text-2xl bold, a change pill tinted by trend, and an optional description. +// Usage: +public class StatCard : Border +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty ChangeProperty = + AvaloniaProperty.Register(nameof(Change)); + + public static readonly StyledProperty TrendProperty = + AvaloniaProperty.Register(nameof(Trend)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + private readonly TextBlock _title; + private readonly Icon _icon; + private readonly TextBlock _value; + private readonly Panel _pill; + private readonly Border _pillTint; + private readonly TextBlock _change; + private readonly TextBlock _description; + + static StatCard() + { + TitleProperty.Changed.AddClassHandler((c, _) => c.Update()); + ValueProperty.Changed.AddClassHandler((c, _) => c.Update()); + ChangeProperty.Changed.AddClassHandler((c, _) => c.Update()); + TrendProperty.Changed.AddClassHandler((c, _) => c.Update()); + DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update()); + IconProperty.Changed.AddClassHandler((c, _) => c.Update()); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Change + { + get => GetValue(ChangeProperty); + set => SetValue(ChangeProperty, value); + } + + public StatTrend Trend + { + get => GetValue(TrendProperty); + set => SetValue(TrendProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public StatCard() + { + _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_icon, 1); + var header = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 8, Children = { _title, _icon } }; + + _value = new TextBlock { FontSize = 24, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }; + _value.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); + + // The pill is the trend color at low strength behind text in the full color. + _pillTint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; + _change = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(6, 2), VerticalAlignment = VerticalAlignment.Center }; + _pill = new Panel { VerticalAlignment = VerticalAlignment.Center, Children = { _pillTint, _change } }; + + _description = new TextBlock { FontSize = 12, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0) }; + _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + Child = new StackPanel + { + Children = + { + header, + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }, + _description + } + }; + Padding = new Thickness(20); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border); + Update(); + } + + private void Update() + { + _title.Text = Title ?? string.Empty; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _value.Text = Value ?? string.Empty; + + _change.Text = Change ?? string.Empty; + _pill.IsVisible = !string.IsNullOrEmpty(Change); + var (tint, strength, text) = Trend switch + { + StatTrend.Up => (ShellToken.Success, 0.12, ShellToken.Success), + StatTrend.Down => (ShellToken.Destructive, 0.12, ShellToken.Destructive), + _ => (ShellToken.Muted, 1.0, ShellToken.MutedForeground) + }; + _pillTint.Token(BackgroundProperty, tint); + _pillTint.Opacity = strength; + _change.Token(TextBlock.ForegroundProperty, text); + + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + } +} + +public enum StatTrend { Neutral, Up, Down } diff --git a/examples/Avalonia.Demo/Components/UI/TagInput.cs b/examples/Avalonia.Demo/Components/UI/TagInput.cs new file mode 100644 index 0000000..1b60ed3 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TagInput.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Tag field — min-h-10 rounded-md border holding chips (rounded-md bg-secondary text-xs) and a +// text field that wraps with them. Enter, a comma or a semicolon turns what was typed into a +// tag; the × on a chip removes it, and Backspace in the empty field removes the last one. +// Usage: +public class TagInput : Border +{ + public static readonly StyledProperty?> TagsProperty = + AvaloniaProperty.Register?>(nameof(Tags), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), "Add tag..."); + + // 0 means no limit. + public static readonly StyledProperty MaxTagsProperty = + AvaloniaProperty.Register(nameof(MaxTags)); + + public static readonly StyledProperty AllowDuplicatesProperty = + AvaloniaProperty.Register(nameof(AllowDuplicates)); + + private static readonly char[] Separators = { ',', ';', '\n', '\t' }; + + private readonly WrapLayout _flow; + private readonly TextBox _textBox; + + static TagInput() + { + TagsProperty.Changed.AddClassHandler((t, e) => t.OnTagsReplaced(e.OldValue, e.NewValue)); + PlaceholderProperty.Changed.AddClassHandler((t, _) => t.UpdatePlaceholder()); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.Opacity = t.IsEnabled ? 1.0 : 0.5); + } + + public IList? Tags + { + get => GetValue(TagsProperty); + set => SetValue(TagsProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public int MaxTags + { + get => GetValue(MaxTagsProperty); + set => SetValue(MaxTagsProperty, value); + } + + public bool AllowDuplicates + { + get => GetValue(AllowDuplicatesProperty); + set => SetValue(AllowDuplicatesProperty, value); + } + + public event EventHandler>? TagsChanged; + + public TagInput() + { + _textBox = new TextBox + { + FontSize = 14, + Height = 28, + Margin = new Thickness(2), + VerticalContentAlignment = VerticalAlignment.Center + }; + ShellPlatform.StripNativeChrome(_textBox); + // Tunnel, so Backspace is seen before the text box handles it. + _textBox.AddHandler(KeyDownEvent, (_, e) => + { + if (e.Key == Key.Enter) + { + AddFromText(); + e.Handled = true; + } + else if (e.Key == Key.Back && string.IsNullOrEmpty(_textBox.Text) && Current.Count > 0) + { + Remove(Current[^1]); + e.Handled = true; + } + }, RoutingStrategies.Tunnel); + _textBox.TextChanged += (_, _) => + { + // Typing or pasting a separator commits everything before it. + if (_textBox.Text?.IndexOfAny(Separators) >= 0) AddFromText(); + }; + _textBox.GotFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Ring); + _textBox.LostFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Input); + + _flow = new WrapLayout { Spacing = 0, LineSpacing = 0, LastChildFill = true, VerticalAlignment = VerticalAlignment.Center, Children = { _textBox } }; + + Child = _flow; + MinHeight = 40; + Padding = new Thickness(8, 3); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Ibeam); + this.Token(BorderBrushProperty, ShellToken.Input); + UpdatePlaceholder(); + } + + // Clicking the empty part of the field focuses the text box. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (IsEffectivelyEnabled && !_textBox.IsFocused) _textBox.Focus(); + } + + private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty(); + + public void Add(string tag) + { + var text = tag.Trim(); + if (text.Length == 0) return; + var current = Current; + if (MaxTags > 0 && current.Count >= MaxTags) return; + if (!AllowDuplicates && current.Contains(text, StringComparer.OrdinalIgnoreCase)) return; + // A new list each time, so a two-way binding sees the change. + Tags = new List(current) { text }; + } + + public void Remove(string tag) + { + var next = new List(Current); + if (next.Remove(tag)) Tags = next; + } + + private void AddFromText() + { + var text = _textBox.Text ?? string.Empty; + _textBox.Text = string.Empty; + foreach (var part in text.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) + Add(part); + } + + private void OnTagsReplaced(object? oldValue, object? newValue) + { + if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnTagsMutated; + if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnTagsMutated; + OnTagsMutated(this, null); + } + + private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e) + { + Rebuild(); + TagsChanged?.Invoke(this, Current); + } + + private void Rebuild() + { + _flow.Children.RemoveAll(_flow.Children.Where(c => c != _textBox).ToList()); + var index = 0; + foreach (var tag in Current) + _flow.Children.Insert(index++, CreateChip(tag)); + UpdatePlaceholder(); + } + + private Control CreateChip(string tag) + { + var label = new TextBlock { Text = tag, FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.SecondaryForeground); + var close = new Icon { Kind = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + // 12px icon in a 20px click target. + var remove = new Border { Width = 20, Height = 20, Background = Brushes.Transparent, Child = close, Cursor = new Cursor(StandardCursorType.Hand) }; + AutomationProperties.SetName(remove, $"Remove {tag}"); + remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive; + remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground; + remove.PointerPressed += (_, e) => e.Handled = true; + remove.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + if (IsEffectivelyEnabled) Remove(tag); + e.Handled = true; + }; + + var chip = new Border + { + Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, Children = { label, remove } }, + Padding = new Thickness(8, 0, 2, 0), + Height = 24, + Margin = new Thickness(2, 3), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Cursor = new Cursor(StandardCursorType.Arrow) + }; + chip.Token(BackgroundProperty, ShellToken.Secondary); + return chip; + } + + private void UpdatePlaceholder() => _textBox.PlaceholderText = Current.Count == 0 ? Placeholder : null; +} diff --git a/examples/Avalonia.Demo/Components/UI/Timeline.cs b/examples/Avalonia.Demo/Components/UI/Timeline.cs new file mode 100644 index 0000000..98b2335 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Timeline.cs @@ -0,0 +1,216 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Vertical timeline — markers joined by a hairline, each with a title, a time and details. +// +// +// +// +// +// +public class Timeline : Border +{ + private readonly StackPanel _stack = new(); + + [Content] + public Controls Items => _stack.Children; + + public Timeline() + { + _stack.Children.CollectionChanged += (_, _) => Refresh(); + Child = _stack; + } + + // The timeline tells each item if it is first or last, so the line starts and stops at a marker. + private void Refresh() + { + var items = _stack.Children.OfType().ToList(); + for (var i = 0; i < items.Count; i++) + items[i].Place(isFirst: i == 0, isLast: i == items.Count - 1); + } +} + +// One entry of a Timeline. Without an Icon the marker is a dot (ringed when active); with one it +// is a 28px circle around the icon. A child control goes under the text. +public class TimelineItem : Border +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty TimeProperty = + AvaloniaProperty.Register(nameof(Time)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IsActiveProperty = + AvaloniaProperty.Register(nameof(IsActive)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + private const double MarkerSize = 28; + + private readonly Border _line; + private readonly Border _ring; + private readonly Border _dot; + private readonly Border _badge; + private readonly Icon _icon; + private readonly TextBlock _title; + private readonly TextBlock _time; + private readonly TextBlock _text; + private readonly Border _body; + private readonly StackPanel _details; + + static TimelineItem() + { + TitleProperty.Changed.AddClassHandler((i, _) => i.Update()); + TimeProperty.Changed.AddClassHandler((i, _) => i.Update()); + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsActiveProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + BodyProperty.Changed.AddClassHandler((i, _) => i.Update()); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Time + { + get => GetValue(TimeProperty); + set => SetValue(TimeProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsActive + { + get => GetValue(IsActiveProperty); + set => SetValue(IsActiveProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public TimelineItem() + { + _line = new Border { Width = 1, HorizontalAlignment = HorizontalAlignment.Center }; + _line.Token(BackgroundProperty, ShellToken.Border); + + // ring-4 ring-primary/20 around the active dot. + _ring = new Border { Width = 18, Height = 18, CornerRadius = new CornerRadius(9), Opacity = 0.2, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _ring.Token(BackgroundProperty, ShellToken.Primary); + _dot = new Border { Width = 10, Height = 10, CornerRadius = new CornerRadius(5), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _icon = new Icon { Size = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _badge = new Border + { + Child = _icon, + Width = MarkerSize, + Height = MarkerSize, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(MarkerSize / 2) + }; + _badge.Token(BackgroundProperty, ShellToken.Background); + var marker = new Panel + { + Width = MarkerSize, + Height = MarkerSize, + VerticalAlignment = VerticalAlignment.Top, + Children = { _ring, _dot, _badge } + }; + + _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _time = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center }; + _time.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _text = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap }; + _text.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _body = new Border(); + _details = new StackPanel + { + Spacing = 2, + Children = + { + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, MinHeight = MarkerSize, Children = { _title, _time } }, + _text, + _body + } + }; + Grid.SetColumn(_details, 1); + + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions("28,*"), + ColumnSpacing = 12, + Children = { _line, marker, _details } + }; + Place(isFirst: true, isLast: true); + Update(); + } + + internal void Place(bool isFirst, bool isLast) + { + // The line runs through the marker's center: from it down (first), up to it (last), or + // the full height (in between). A lone item has no line. + _line.IsVisible = !(isFirst && isLast); + if (isLast) + { + _line.VerticalAlignment = VerticalAlignment.Top; + _line.Height = MarkerSize / 2; + _line.Margin = new Thickness(0); + } + else + { + _line.VerticalAlignment = VerticalAlignment.Stretch; + _line.Height = double.NaN; + _line.Margin = new Thickness(0, isFirst ? MarkerSize / 2 : 0, 0, 0); + } + _details.Margin = new Thickness(0, 0, 0, isLast ? 0 : 24); + } + + private void Update() + { + var hasIcon = Icon != IconName.None; + _badge.IsVisible = hasIcon; + _dot.IsVisible = !hasIcon; + _ring.IsVisible = !hasIcon && IsActive; + _icon.Kind = Icon; + _icon.Token = IsActive ? ShellToken.Primary : ShellToken.MutedForeground; + _badge.Token(BorderBrushProperty, IsActive ? ShellToken.Primary : ShellToken.Border); + _dot.Token(BackgroundProperty, IsActive ? ShellToken.Primary : ShellToken.MutedForeground); + + _title.Text = Title ?? string.Empty; + _time.Text = Time ?? string.Empty; + _time.IsVisible = !string.IsNullOrEmpty(Time); + _text.Text = Text ?? string.Empty; + _text.IsVisible = !string.IsNullOrEmpty(Text); + _body.Child = Body; + _body.IsVisible = Body != null; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs b/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs new file mode 100644 index 0000000..5977b14 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/ToggleGroup.cs @@ -0,0 +1,263 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// A row of toggles that work as one control: pick one (default) or several (Multiple). +// +// +// +// +// ... +public class ToggleGroup : Border +{ + public static readonly StyledProperty MultipleProperty = + AvaloniaProperty.Register(nameof(Multiple)); + + // The pressed item's Value in single mode; null when nothing is pressed. + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + // The pressed items' Values in Multiple mode. + public static readonly StyledProperty?> ValuesProperty = + AvaloniaProperty.Register?>(nameof(Values), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + + private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 }; + + static ToggleGroup() + { + MultipleProperty.Changed.AddClassHandler((g, _) => g.Refresh()); + VariantProperty.Changed.AddClassHandler((g, _) => g.Refresh()); + ValueProperty.Changed.AddClassHandler((g, _) => + { + g.Refresh(); + g.ValueChanged?.Invoke(g, g.Value); + }); + ValuesProperty.Changed.AddClassHandler((g, e) => g.OnValuesReplaced(e.OldValue, e.NewValue)); + } + + public bool Multiple + { + get => GetValue(MultipleProperty); + set => SetValue(MultipleProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IList? Values + { + get => GetValue(ValuesProperty); + set => SetValue(ValuesProperty, value); + } + + public ToggleGroupVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + [Content] + public Controls Items => _row.Children; + + public event EventHandler? ValueChanged; + public event EventHandler>? ValuesChanged; + + public ToggleGroup() + { + _row.Children.CollectionChanged += (_, _) => Refresh(); + Child = _row; + HorizontalAlignment = HorizontalAlignment.Left; + } + + public bool IsPressed(string? value) => + value != null && (Multiple ? Values?.Contains(value) == true : Value == value); + + internal void Toggle(string? value) + { + if (value is null || !IsEffectivelyEnabled) return; + if (Multiple) + { + // A new list each time, so a two-way binding sees the change. + var next = new List(Values ?? Array.Empty()); + if (!next.Remove(value)) next.Add(value); + Values = next; + } + else + { + Value = Value == value ? null : value; + } + } + + private void OnValuesReplaced(object? oldValue, object? newValue) + { + if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated; + if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated; + OnValuesMutated(this, null); + } + + private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) + { + Refresh(); + ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); + } + + // Items don't track the group's state: the group pushes it to them. + private void Refresh() + { + foreach (var item in _row.Children.OfType()) + item.Apply(IsPressed(item.Value), Variant == ToggleGroupVariant.Outline); + } +} + +// One toggle in a ToggleGroup — h-9 min-w-9 rounded-md px-2.5, bg-accent while pressed. A tab +// stop that toggles with Enter or Space. +public class ToggleGroupItem : Border, IShellFocusable +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private bool _pressed; + private bool _outline; + + static ToggleGroupItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update()); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public ToggleGroupItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { _icon, _label } + }; + Height = 36; + MinWidth = 36; + Padding = new Thickness(10, 0); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + Press(); + e.Handled = true; + } + + private void Press() + { + if (IsEffectivelyEnabled) this.FindParentOfType()?.Toggle(Value); + } + + internal void Apply(bool pressed, bool outline) + { + _pressed = pressed; + _outline = outline; + Update(); + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _label.IsVisible = !string.IsNullOrEmpty(Text); + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + AutomationProperties.SetName(this, string.IsNullOrEmpty(Text) ? Icon.ToString() : Text); + + BorderThickness = new Thickness(_outline ? 1 : 0); + this.Token(BorderBrushProperty, ShellToken.Input); + + var hovered = IsPointerOver && IsEffectivelyEnabled; + var foreground = _pressed ? ShellToken.AccentForeground + : hovered ? ShellToken.MutedForeground + : ShellToken.Foreground; + _label.Token(TextBlock.ForegroundProperty, foreground); + _icon.Token = foreground; + + if (_pressed) this.Token(BackgroundProperty, ShellToken.Accent); + else if (hovered) this.Token(BackgroundProperty, ShellToken.Muted); + else + { + // Transparent, not null, so the whole toggle stays hit-testable. + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + Opacity = IsEnabled ? 1.0 : 0.5; + } +} + +public enum ToggleGroupVariant { Default, Outline } diff --git a/examples/Avalonia.Demo/Components/UI/TreeView.cs b/examples/Avalonia.Demo/Components/UI/TreeView.cs new file mode 100644 index 0000000..299a19a --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TreeView.cs @@ -0,0 +1,218 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Tree of expandable rows — rounded-md px-2 py-1.5 text-sm rows, hover:bg-muted, bg-accent when +// selected; children are indented behind a hairline. Clicking a row selects it and, when it has +// children, expands or collapses it. Rows are tab stops: Enter / Space act like a click, Right +// expands and Left collapses. +// +// +// +// +// +public class TreeView : Border +{ + // The selected item's Value (its Text when no Value is set). + public static readonly StyledProperty SelectedValueProperty = + AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay); + + private readonly StackPanel _stack = new() { Spacing = 2 }; + + static TreeView() + { + SelectedValueProperty.Changed.AddClassHandler((t, _) => + { + t.Refresh(); + t.SelectedValueChanged?.Invoke(t, t.SelectedValue); + }); + } + + public string? SelectedValue + { + get => GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + [Content] + public Controls Items => _stack.Children; + + public event EventHandler? SelectedValueChanged; + + public TreeView() + { + Child = _stack; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(); + } + + // Items don't track the selection: the tree pushes it to every item under it. + private void Refresh() + { + foreach (var item in this.FindDescendantsOfType()) + item.ApplySelection(SelectedValue); + } +} + +// One row of a TreeView. Nested TreeViewItems become its children. +public class TreeViewItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // Identifies the item for selection. Defaults to Text. + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded), defaultBindingMode: BindingMode.TwoWay); + + private readonly Border _row; + private readonly Icon _chevron; + private readonly Icon _icon; + private readonly TextBlock _label; + private readonly Panel _childrenHost; + private readonly StackPanel _children = new() { Spacing = 2, Margin = new Thickness(25, 0, 0, 0) }; + private bool _selected; + + static TreeViewItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsExpandedProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + [Content] + public Controls Items => _children.Children; + + public string EffectiveValue => Value ?? Text ?? string.Empty; + + public TreeViewItem() + { + _chevron = new Icon { Kind = IconName.ChevronRight, Size = 16, Token = ShellToken.MutedForeground }; + _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_icon, 1); + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + Grid.SetColumn(_label, 2); + + // The chevron keeps its 16px slot on leaves, so labels line up within a level. + var slot = new Panel { Width = 16, Height = 16, VerticalAlignment = VerticalAlignment.Center, Children = { _chevron } }; + _row = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,Auto,*"), ColumnSpacing = 6, Children = { slot, _icon, _label } }, + Height = 32, + Padding = new Thickness(8, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + ShellFocus.Ring(_row, _row); + _row.PointerEntered += (_, _) => Paint(); + _row.PointerExited += (_, _) => Paint(); + _row.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + }; + _row.KeyDown += OnRowKeyDown; + + // ml-4 border-l pl-2: a hairline under the parent's chevron, children to its right. + var guide = new Border { Width = 1, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(16, 0, 0, 0) }; + guide.Token(BackgroundProperty, ShellToken.Border); + _children.Children.CollectionChanged += (_, _) => Update(); + _childrenHost = new Panel { Margin = new Thickness(0, 2, 0, 0), IsVisible = false, Children = { guide, _children } }; + + Child = new StackPanel { Children = { _row, _childrenHost } }; + Update(); + } + + private bool HasChildren => _children.Children.Count > 0; + + private void OnRowKeyDown(object? sender, KeyEventArgs e) + { + if (!IsEffectivelyEnabled) return; + if (e.Key is Key.Enter or Key.Space) Press(); + else if (e.Key == Key.Right && HasChildren && !IsExpanded) IsExpanded = true; + else if (e.Key == Key.Left && HasChildren && IsExpanded) IsExpanded = false; + else return; + e.Handled = true; + } + + private void Press() + { + if (!IsEffectivelyEnabled) return; + if (HasChildren) IsExpanded = !IsExpanded; + if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue; + } + + internal void ApplySelection(string? selected) + { + _selected = selected != null && selected == EffectiveValue; + Paint(); + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _chevron.IsVisible = HasChildren; + _chevron.Kind = IsExpanded ? IconName.ChevronDown : IconName.ChevronRight; + _childrenHost.IsVisible = HasChildren && IsExpanded; + Paint(); + } + + private void Paint() + { + _label.FontWeight = _selected ? FontWeight.Medium : FontWeight.Normal; + _label.Token(TextBlock.ForegroundProperty, _selected ? ShellToken.AccentForeground : ShellToken.Foreground); + if (_selected) _row.Token(BackgroundProperty, ShellToken.Accent); + else if (_row.IsPointerOver && IsEffectivelyEnabled) _row.Token(BackgroundProperty, ShellToken.Muted); + else + { + // Transparent, not null, so the whole row stays hit-testable. + _row.ClearToken(BackgroundProperty); + _row.Background = Brushes.Transparent; + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/WrapLayout.cs b/examples/Avalonia.Demo/Components/UI/WrapLayout.cs new file mode 100644 index 0000000..a330e21 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/WrapLayout.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// flex flex-wrap gap-2 — children flow left to right and wrap onto new rows, centered vertically +// within their row. Use it for button rows, chips and tag lists. +// +// ... +// +// Unlike WrapPanel, the last child can take the rest of its row (LastChildFill). +public class WrapLayout : Panel +{ + // Gap between children in a row. + public static readonly StyledProperty SpacingProperty = + AvaloniaProperty.Register(nameof(Spacing), 8); + + // Gap between rows. + public static readonly StyledProperty LineSpacingProperty = + AvaloniaProperty.Register(nameof(LineSpacing), 8); + + // The last child takes what is left of its row (or a row of its own when less than + // MinimumLastChildWidth remains) — e.g. the text field after a run of chips. + public static readonly StyledProperty LastChildFillProperty = + AvaloniaProperty.Register(nameof(LastChildFill)); + + public static readonly StyledProperty MinimumLastChildWidthProperty = + AvaloniaProperty.Register(nameof(MinimumLastChildWidth), 96); + + static WrapLayout() + { + AffectsMeasure(SpacingProperty, LineSpacingProperty, LastChildFillProperty, MinimumLastChildWidthProperty); + } + + public double Spacing + { + get => GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + public double LineSpacing + { + get => GetValue(LineSpacingProperty); + set => SetValue(LineSpacingProperty, value); + } + + public bool LastChildFill + { + get => GetValue(LastChildFillProperty); + set => SetValue(LastChildFillProperty, value); + } + + public double MinimumLastChildWidth + { + get => GetValue(MinimumLastChildWidthProperty); + set => SetValue(MinimumLastChildWidthProperty, value); + } + + protected override Size MeasureOverride(Size availableSize) => Flow(availableSize.Width, arrange: false); + + protected override Size ArrangeOverride(Size finalSize) + { + Flow(finalSize.Width, arrange: true); + return finalSize; + } + + private Size Flow(double available, bool arrange) + { + var bounded = !double.IsInfinity(available); + var row = new List<(Control Child, double X, double Width, double Height)>(); + double x = 0, y = 0, rowHeight = 0, widest = 0; + + var last = -1; + for (var i = Children.Count - 1; i >= 0 && last < 0; i--) + if (Children[i].IsVisible) last = i; + + void EndRow() + { + if (arrange) + { + foreach (var (child, left, width, height) in row) + child.Arrange(new Rect(left, y + (rowHeight - height) / 2, width, height)); + } + row.Clear(); + y += rowHeight + LineSpacing; + x = 0; + rowHeight = 0; + } + + for (var i = 0; i <= last; i++) + { + var child = Children[i]; + if (!child.IsVisible) continue; + + var start = row.Count > 0 ? x + Spacing : 0; + double width, height; + if (LastChildFill && i == last) + { + var rest = bounded ? available - start : MinimumLastChildWidth; + if (bounded && rest < MinimumLastChildWidth && row.Count > 0) + { + EndRow(); + start = 0; + rest = available; + } + width = Math.Max(0, rest); + if (!arrange) child.Measure(new Size(width, double.PositiveInfinity)); + height = child.DesiredSize.Height; + } + else + { + if (!arrange) child.Measure(new Size(available, double.PositiveInfinity)); + var size = child.DesiredSize; + width = bounded ? Math.Min(size.Width, available) : size.Width; + height = size.Height; + // Half a pixel of slack so rounding can't wrap differently between passes. + if (bounded && row.Count > 0 && start + width > available + 0.5) + { + EndRow(); + start = 0; + } + } + + row.Add((child, start, width, height)); + x = start + width; + rowHeight = Math.Max(rowHeight, height); + widest = Math.Max(widest, x); + } + + var total = y + rowHeight; + if (row.Count > 0) EndRow(); + return new Size(widest, last < 0 ? 0 : total); + } +} diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 2dbe813..36071f9 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -427,6 +427,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 0013f69..5850417 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -50,6 +50,24 @@ public MainWindow() DemoStepper.CurrentStepChanged += (_, step) => AdvancedChoice.Text = $"Step {step + 1} of 3."; DemoStepper.Confirmed += (_, _) => Toast.Success("Workspace created"); + var frameworks = new[] { ".NET MAUI", "Avalonia", "Blazor", "Uno Platform", "WinUI", "WPF", "Flutter", "React Native", "SwiftUI", "Jetpack Compose" }; + FrameworkCombobox.ItemsSource = frameworks; + FrameworkMultiSelect.ItemsSource = frameworks; + FrameworkMultiSelect.Values = new[] { "Avalonia", "Blazor" }; + DemoTags.Tags = new[] { "avalonia", "design-system" }; + ViewToggleGroup.ValueChanged += (_, value) => FormChoice.Text = $"Toggle group: {value ?? "none"}."; + MarksToggleGroup.ValuesChanged += (_, values) => FormChoice.Text = $"Toggle group: {(values.Count == 0 ? "none" : string.Join(", ", values))}."; + DemoOtp.Completed += (_, code) => FormChoice.Text = $"OTP complete: {code}."; + QuantityInput.ValueChanged += (_, value) => FormChoice.Text = $"Number: {(value.HasValue ? value.ToString() : "empty")}."; + DemoTags.TagsChanged += (_, tags) => FormChoice.Text = $"Tags: {string.Join(", ", tags)}."; + FrameworkCombobox.ValueChanged += (_, value) => FormChoice.Text = $"Combobox: {value}."; + FrameworkMultiSelect.ValuesChanged += (_, values) => FormChoice.Text = $"Multi select: {(values.Count == 0 ? "none" : string.Join(", ", values))}."; + CopyCommand.Copied += (_, _) => DisplayChoice.Text = $"Copied: {CopyCommand.Text}."; + CopyLink.Copied += (_, _) => DisplayChoice.Text = $"Copied: {CopyLink.Text}."; + DocsCard.Clicked += (_, _) => DisplayChoice.Text = $"Link card: {DocsCard.Title}."; + SourceCard.Clicked += (_, _) => DisplayChoice.Text = $"Link card: {SourceCard.Title}."; + FileTree.SelectedValueChanged += (_, value) => DisplayChoice.Text = $"Tree: {value}."; + for (var i = 1; i <= 30; i++) { var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 }; diff --git a/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs b/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs index 507c648..54c412c 100644 --- a/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AspectRatioTemplate.cs @@ -50,6 +50,49 @@ private void Fit() if (Math.Abs(HeightRequest - height) > 0.5) HeightRequest = height; } } +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Keeps its content at a fixed width / height ratio: the height follows the width. +// +// +// +public class AspectRatio : Border +{ + // Width divided by height. 16:9 is 1.7778, 4:3 is 1.3333, a square is 1. + public static readonly StyledProperty RatioProperty = + AvaloniaProperty.Register(nameof(Ratio), 16d / 9d); + + static AspectRatio() + { + AffectsMeasure(RatioProperty); + } + + public double Ratio + { + get => GetValue(RatioProperty); + set => SetValue(RatioProperty, value); + } + + public AspectRatio() + { + // With a CornerRadius the content is clipped to the rounded shape. + ClipToBounds = true; + } + + protected override Size MeasureOverride(Size availableSize) + { + if (Ratio <= 0) return base.MeasureOverride(availableSize); + // Without a width to fill (e.g. in a horizontal stack) it takes the content's width. + var width = double.IsInfinity(availableSize.Width) ? base.MeasureOverride(availableSize).Width : availableSize.Width; + var size = new Size(width, width / Ratio); + base.MeasureOverride(size); + return size; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs b/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs index 1fb1291..4a79d47 100644 --- a/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ComboboxTemplate.cs @@ -305,6 +305,324 @@ private void Filter() _empty.IsVisible = !any; } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace YourProjectNamespace.Components.UI; + +// Combobox — a Select with a search field: h-10 trigger, and a floating panel with a filter +// input above the option list (check on the selected option, ""no results"" when nothing matches). +// Type to filter, Up / Down to move, Enter to pick, Escape to close. +// Usage: then set +// ItemsSource in code or by binding. +public class Combobox : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty?> ItemsSourceProperty = + AvaloniaProperty.Register?>(nameof(ItemsSource)); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), ""Select...""); + + public static readonly StyledProperty SearchPlaceholderProperty = + AvaloniaProperty.Register(nameof(SearchPlaceholder), ""Search...""); + + public static readonly StyledProperty EmptyTextProperty = + AvaloniaProperty.Register(nameof(EmptyText), ""No results found.""); + + private const double ItemHeight = 32; + private const double MaxListHeight = 240; + + private readonly Border _trigger; + private readonly TextBlock _value; + private readonly Border _panel; + private readonly TextBox _search; + private readonly StackPanel _list = new(); + private readonly Panel _listArea; + private readonly TextBlock _empty; + private readonly List<(string Text, Border Row, Icon Check)> _rows = new(); + private readonly ShellAnchoredPopup _popup; + private int _active = -1; + + static Combobox() + { + ValueProperty.Changed.AddClassHandler((c, _) => + { + c.UpdateTrigger(); + c.UpdateChecks(); + c.ValueChanged?.Invoke(c, c.Value ?? string.Empty); + }); + ItemsSourceProperty.Changed.AddClassHandler((c, _) => c.RebuildItems()); + PlaceholderProperty.Changed.AddClassHandler((c, _) => c.UpdateTrigger()); + SearchPlaceholderProperty.Changed.AddClassHandler((c, e) => c._search.PlaceholderText = (string?)e.NewValue); + EmptyTextProperty.Changed.AddClassHandler((c, e) => c._empty.Text = (string?)e.NewValue); + IsEnabledProperty.Changed.AddClassHandler((c, _) => c.Opacity = c.IsEnabled ? 1.0 : 0.5); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IList? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string? SearchPlaceholder + { + get => GetValue(SearchPlaceholderProperty); + set => SetValue(SearchPlaceholderProperty, value); + } + + public string? EmptyText + { + get => GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public bool IsOpen => _popup.IsOpen; + + public event EventHandler? ValueChanged; + + public Combobox() + { + _value = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + var chevrons = new Icon { Kind = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground }; + Grid.SetColumn(chevrons, 1); + _trigger = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 8, Children = { _value, chevrons } }, + Height = 40, + Padding = new Thickness(12, 0), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + _trigger.Token(BorderBrushProperty, ShellToken.Input); + _trigger.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + SetOpen(!IsOpen); + e.Handled = true; + }; + + _search = new TextBox { FontSize = 14, PlaceholderText = SearchPlaceholder, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center }; + ShellPlatform.StripNativeChrome(_search); + _search.TextChanged += (_, _) => Filter(); + _search.KeyDown += OnSearchKeyDown; + Grid.SetColumn(_search, 1); + var searchRow = new Border + { + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions(""Auto,*""), + ColumnSpacing = 8, + Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _search } + }, + Height = 40, + Padding = new Thickness(12, 0) + }; + + var divider = new Border { Height = 1 }; + divider.Token(BackgroundProperty, ShellToken.Border); + + _empty = new TextBlock { Text = EmptyText, FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering. + _listArea = new Panel { Margin = new Thickness(4), Children = { new ScrollViewer { Content = _list }, _empty } }; + + _panel = new Border + { + Child = new StackPanel { Children = { searchRow, divider, _listArea } }, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + BoxShadow = ShellPopups.PanelShadow() + }; + _panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + + _popup = new ShellAnchoredPopup(_trigger); + _popup.Popup.Child = _panel; + _popup.Closed += (_, _) => + { + _trigger.Token(BorderBrushProperty, ShellToken.Input); + // Focus goes back to the trigger instead of being lost with the panel. + if (_search.IsFocused) Focus(); + }; + + Child = new Panel { Children = { _trigger, _popup.Popup } }; + ShellFocus.Ring(this, _trigger); + RebuildItems(); + } + + public void SetOpen(bool open) + { + if (open == _popup.IsOpen) return; + if (!open) + { + _popup.Close(); + return; + } + _search.Text = string.Empty; + Filter(); + // The panel matches the trigger's width. + _panel.Width = _trigger.Bounds.Width; + _trigger.Token(BorderBrushProperty, ShellToken.Ring); + _popup.Open(); + Dispatcher.UIThread.Post(() => + { + if (IsOpen) _search.Focus(); + }, DispatcherPriority.Loaded); + } + + public void Close() => SetOpen(false); + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + // Only keys pressed on the closed trigger: the search field handles its own. + if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return; + SetOpen(true); + e.Handled = true; + } + + private void OnSearchKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + if (_active >= 0) Pick(_rows[_active].Text); + } + else if (e.Key == Key.Down) MoveActive(1); + else if (e.Key == Key.Up) MoveActive(-1); + else return; + e.Handled = true; + } + + private void Pick(string text) + { + Value = text; + SetOpen(false); + } + + private void UpdateTrigger() + { + var hasValue = !string.IsNullOrEmpty(Value); + _value.Text = hasValue ? Value : Placeholder ?? string.Empty; + _value.Token(TextBlock.ForegroundProperty, hasValue ? ShellToken.Foreground : ShellToken.MutedForeground); + } + + private void RebuildItems() + { + _list.Children.Clear(); + _rows.Clear(); + _active = -1; + var items = ItemsSource; + _listArea.Height = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight); + if (items != null) + { + foreach (var text in items) + _list.Children.Add(CreateItem(text, _rows.Count)); + } + UpdateTrigger(); + UpdateChecks(); + Filter(); + } + + private Control CreateItem(string text, int index) + { + var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground); + var check = new Icon { Kind = IconName.Check, Size = 16, IsVisible = false, Token = ShellToken.PopoverForeground }; + Grid.SetColumn(check, 1); + + var item = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 8, Children = { label, check } }, + Height = ItemHeight, + Padding = new Thickness(8, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + // The pointer and the arrow keys move the same highlight. + item.PointerMoved += (_, _) => SetActive(index); + item.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + Pick(text); + e.Handled = true; + }; + + _rows.Add((text, item, check)); + return item; + } + + private void UpdateChecks() + { + foreach (var (text, _, check) in _rows) + check.IsVisible = text == Value; + } + + private void Filter() + { + var query = _search.Text?.Trim() ?? string.Empty; + var first = -1; + for (var i = 0; i < _rows.Count; i++) + { + var (text, row, _) = _rows[i]; + row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase); + if (row.IsVisible && first < 0) first = i; + } + _empty.IsVisible = first < 0; + SetActive(first); + } + + private void MoveActive(int direction) + { + for (var i = _active + direction; i >= 0 && i < _rows.Count; i += direction) + { + if (!_rows[i].Row.IsVisible) continue; + SetActive(i); + _rows[i].Row.BringIntoView(); + return; + } + } + + private void SetActive(int index) + { + if (index == _active) return; + _active = index; + for (var i = 0; i < _rows.Count; i++) + { + var row = _rows[i].Row; + if (i == index) row.Token(BackgroundProperty, ShellToken.Accent); + else + { + row.ClearToken(BackgroundProperty); + row.Background = Brushes.Transparent; + } + } + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs b/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs index f262214..d3b07f3 100644 --- a/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CopyButtonTemplate.cs @@ -149,6 +149,153 @@ protected override void OnPropertyChanged(string? propertyName = null) if (propertyName == IsEnabledProperty.PropertyName && _border != null) Update(); } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Copies Text to the clipboard — a ghost icon button (h-9 w-9 rounded-md, hover:bg-accent) whose +// icon turns into a check for two seconds after copying. Label adds text next to the icon. +// Usage: +public class CopyButton : Border, IShellFocusable +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private bool _copied; + private int _version; + + static CopyButton() + { + TextProperty.Changed.AddClassHandler((b, _) => b.Update()); + LabelProperty.Changed.AddClassHandler((b, _) => b.Update()); + IsEnabledProperty.Changed.AddClassHandler((b, _) => b.Update()); + } + + // The text placed on the clipboard. + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Label + { + get => GetValue(LabelProperty); + set => SetValue(LabelProperty, value); + } + + public event EventHandler? Copied; + + public CopyButton() + { + _icon = new Icon { Kind = IconName.Copy, Size = 16, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { _icon, _label } + }; + Height = 36; + MinWidth = 36; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + HorizontalAlignment = HorizontalAlignment.Left; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return; + _ = CopyAsync(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + _ = CopyAsync(); + e.Handled = true; + } + + public async Task CopyAsync() + { + if (!IsEffectivelyEnabled || string.IsNullOrEmpty(Text)) return; + if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return; + try + { + await clipboard.SetTextAsync(Text); + } + catch (Exception) + { + // No clipboard access: leave the icon as it is. + return; + } + + var version = ++_version; + _copied = true; + Update(); + Copied?.Invoke(this, EventArgs.Empty); + await Task.Delay(2000); + if (version != _version) return; + _copied = false; + Update(); + } + + private void Update() + { + var hasLabel = !string.IsNullOrEmpty(Label); + _label.Text = Label ?? string.Empty; + _label.IsVisible = hasLabel; + Padding = new Thickness(hasLabel ? 12 : 0, 0); + + var hovered = IsPointerOver && IsEffectivelyEnabled; + _icon.Kind = _copied ? IconName.Check : IconName.Copy; + _icon.Token = _copied ? ShellToken.Success : hovered ? ShellToken.AccentForeground : ShellToken.Foreground; + _label.Token(TextBlock.ForegroundProperty, hovered ? ShellToken.AccentForeground : ShellToken.Foreground); + + if (hovered) this.Token(BackgroundProperty, ShellToken.Accent); + else + { + // Transparent, not null, so the whole button stays hit-testable. + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + Opacity = IsEnabled && !string.IsNullOrEmpty(Text) ? 1.0 : 0.5; + AutomationProperties.SetName(this, _copied ? ""Copied"" : ""Copy to clipboard""); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs b/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs index 6841406..0de5335 100644 --- a/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/InputOtpTemplate.cs @@ -192,6 +192,206 @@ private void UpdateSlots() } } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Threading; + +namespace YourProjectNamespace.Components.UI; + +// One-time-code input — a row of 40px slots (rounded-md border border-input) over one invisible +// text box, so typing, paste and input methods all work. The active slot shows the ring color +// and a blinking caret. +// Usage: +public class InputOtp : Border +{ + public static readonly StyledProperty LengthProperty = + AvaloniaProperty.Register(nameof(Length), 6); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), string.Empty, defaultBindingMode: BindingMode.TwoWay); + + // Digits only. False allows letters too. + public static readonly StyledProperty IsNumericProperty = + AvaloniaProperty.Register(nameof(IsNumeric), true); + + public static readonly StyledProperty HasErrorProperty = + AvaloniaProperty.Register(nameof(HasError)); + + private const double SlotSize = 40; + + private readonly TextBox _textBox; + private readonly StackPanel _slots = new() { Orientation = Orientation.Horizontal, Spacing = 8, IsHitTestVisible = false }; + private readonly List<(Border Box, TextBlock Text, Border Caret)> _items = new(); + private DispatcherTimer? _blink; + private bool _focused; + + static InputOtp() + { + LengthProperty.Changed.AddClassHandler((o, _) => o.BuildSlots()); + ValueProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged()); + IsNumericProperty.Changed.AddClassHandler((o, _) => o.OnValueChanged()); + HasErrorProperty.Changed.AddClassHandler((o, _) => o.UpdateSlots()); + IsEnabledProperty.Changed.AddClassHandler((o, _) => o.Opacity = o.IsEnabled ? 1.0 : 0.5); + } + + public int Length + { + get => GetValue(LengthProperty); + set => SetValue(LengthProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public bool IsNumeric + { + get => GetValue(IsNumericProperty); + set => SetValue(IsNumericProperty, value); + } + + public bool HasError + { + get => GetValue(HasErrorProperty); + set => SetValue(HasErrorProperty, value); + } + + public event EventHandler? ValueChanged; + // Raised when every slot is filled. + public event EventHandler? Completed; + + public InputOtp() + { + // The real input: stretched over the slots and invisible, so a click anywhere focuses it. + _textBox = new TextBox { Opacity = 0, Cursor = new Cursor(StandardCursorType.Ibeam) }; + ShellPlatform.StripNativeChrome(_textBox); + AutomationProperties.SetName(_textBox, ""Verification code""); + _textBox.TextChanged += (_, _) => + { + // Characters that don't belong (or don't fit) never stay in the hidden text. + var value = Sanitize(_textBox.Text); + if (_textBox.Text != value) + { + _textBox.Text = value; + _textBox.CaretIndex = value.Length; + } + Value = value; + }; + _textBox.GotFocus += (_, _) => + { + _focused = true; + _textBox.CaretIndex = _textBox.Text?.Length ?? 0; + UpdateSlots(); + }; + _textBox.LostFocus += (_, _) => + { + _focused = false; + UpdateSlots(); + }; + + Child = new Panel { Children = { _slots, _textBox } }; + HorizontalAlignment = HorizontalAlignment.Left; + BuildSlots(); + } + + public new bool Focus(NavigationMethod method = NavigationMethod.Unspecified, KeyModifiers keyModifiers = KeyModifiers.None) + => _textBox.Focus(method, keyModifiers); + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + StopBlink(); + } + + private int SlotCount => Math.Max(1, Length); + + private string Sanitize(string? text) + { + var filtered = new string((text ?? string.Empty).Where(c => IsNumeric ? char.IsDigit(c) : char.IsLetterOrDigit(c)).ToArray()); + return filtered.Length > SlotCount ? filtered[..SlotCount] : filtered; + } + + private void BuildSlots() + { + _slots.Children.Clear(); + _items.Clear(); + for (var i = 0; i < SlotCount; i++) + { + var text = new TextBlock { FontSize = 16, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + var caret = new Border { Width = 1.5, Height = 18, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + caret.Token(BackgroundProperty, ShellToken.Foreground); + var box = new Border + { + Child = new Panel { Children = { text, caret } }, + Width = SlotSize, + Height = SlotSize, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd) + }; + _items.Add((box, text, caret)); + _slots.Children.Add(box); + } + OnValueChanged(); + } + + private void OnValueChanged() + { + var value = Sanitize(Value); + if (value != Value) + { + Value = value; + return; + } + if (_textBox.Text != value) + { + _textBox.Text = value; + _textBox.CaretIndex = value.Length; + } + UpdateSlots(); + ValueChanged?.Invoke(this, value); + if (value.Length == Length && Length > 0) Completed?.Invoke(this, value); + } + + private void UpdateSlots() + { + var value = Value ?? string.Empty; + // The caret sits in the next empty slot (the last one when the code is complete). + var active = _focused ? Math.Min(value.Length, _items.Count - 1) : -1; + for (var i = 0; i < _items.Count; i++) + { + var (box, text, caret) = _items[i]; + text.Text = i < value.Length ? value[i].ToString() : string.Empty; + var isActive = i == active; + box.Token(BorderBrushProperty, HasError ? ShellToken.Destructive : isActive ? ShellToken.Ring : ShellToken.Input); + caret.IsVisible = isActive && i >= value.Length; + caret.Opacity = 1; + } + + StopBlink(); + if (active < 0 || active < value.Length) return; + var blinking = _items[active].Caret; + _blink = new DispatcherTimer(TimeSpan.FromMilliseconds(500), DispatcherPriority.Normal, + (_, _) => blinking.Opacity = blinking.Opacity > 0.5 ? 0 : 1); + _blink.Start(); + } + + private void StopBlink() + { + _blink?.Stop(); + _blink = null; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs b/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs index 1bdbda8..89dab7c 100644 --- a/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/KbdTemplate.cs @@ -66,6 +66,56 @@ public Kbd() VerticalOptions = LayoutOptions.Center; } } +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Keyboard key — h-5 rounded border bg-muted px-1.5 text-[11px] font-medium text-muted-foreground. +// Usage: +public class Kbd : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + private readonly TextBlock _label; + + static Kbd() + { + TextProperty.Changed.AddClassHandler((k, e) => k._label.Text = (string?)e.NewValue ?? string.Empty); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public Kbd() + { + _label = new TextBlock + { + FontSize = 11, + FontWeight = FontWeight.Medium, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + Child = _label; + Height = 22; + MinWidth = 22; + Padding = new Thickness(6, 0); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusSm); + HorizontalAlignment = HorizontalAlignment.Left; + VerticalAlignment = VerticalAlignment.Center; + this.Token(BackgroundProperty, ShellToken.Muted).Token(BorderBrushProperty, ShellToken.Border); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs index fc37412..93d7032 100644 --- a/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/LinkCardTemplate.cs @@ -143,6 +143,153 @@ private void Update() _description.IsVisible = !string.IsNullOrEmpty(Description); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Clickable card — rounded-lg border bg-card p-4, hover:bg-accent: an optional icon, a title, a +// muted description and an arrow. A click (or Enter / Space) raises Clicked and opens Url when +// one is set. +// Usage: +public class LinkCard : Border, IShellFocusable +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + // Opened in the system browser (or the app registered for the link) when the card is clicked. + public static readonly StyledProperty UrlProperty = + AvaloniaProperty.Register(nameof(Url)); + + private readonly Icon _icon; + private readonly TextBlock _title; + private readonly TextBlock _description; + + static LinkCard() + { + TitleProperty.Changed.AddClassHandler((c, _) => c.Update()); + DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update()); + IconProperty.Changed.AddClassHandler((c, _) => c.Update()); + IsEnabledProperty.Changed.AddClassHandler((c, _) => c.Opacity = c.IsEnabled ? 1.0 : 0.5); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string? Url + { + get => GetValue(UrlProperty); + set => SetValue(UrlProperty, value); + } + + public event EventHandler? Clicked; + + public LinkCard() + { + _icon = new Icon { Size = 20, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 1, 0, 0) }; + _title = new TextBlock { FontSize = 15, FontWeight = FontWeight.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); + _description = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap }; + _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + var text = new StackPanel { Spacing = 4, Children = { _title, _description } }; + Grid.SetColumn(text, 1); + var arrow = new Icon { Kind = IconName.ArrowUpRight, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) }; + Grid.SetColumn(arrow, 2); + + Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""Auto,*,Auto""), ColumnSpacing = 12, Children = { _icon, text, arrow } }; + Padding = new Thickness(16); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + Cursor = new Cursor(StandardCursorType.Hand); + this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + if (IsEffectivelyEnabled) this.Token(BackgroundProperty, ShellToken.Accent); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + this.Token(BackgroundProperty, ShellToken.Card); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !new Rect(Bounds.Size).Contains(e.GetPosition(this))) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + Press(); + e.Handled = true; + } + + // Clicks in code, as a pointer click or Enter/Space would. + public async void Press() + { + if (!IsEffectivelyEnabled) return; + Clicked?.Invoke(this, EventArgs.Empty); + if (string.IsNullOrWhiteSpace(Url) || !Uri.TryCreate(Url, UriKind.Absolute, out var uri)) return; + if (TopLevel.GetTopLevel(this) is not { } top) return; + try + { + await top.Launcher.LaunchUriAsync(uri); + } + catch (Exception) + { + // Nothing on the device can open this link. + } + } + + private void Update() + { + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + AutomationProperties.SetName(this, Title ?? string.Empty); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs b/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs index 01d6f8e..160c397 100644 --- a/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/MultiSelectTemplate.cs @@ -369,6 +369,384 @@ private void Filter() _empty.IsVisible = !any; } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Threading; + +namespace YourProjectNamespace.Components.UI; + +// Multi-select — a min-h-10 trigger that shows the picked options as removable chips, and a +// floating panel with a search field and a checked list. Picking toggles an option and leaves +// the panel open; click outside or press Escape to close it. Type to filter, Up / Down to move, +// Enter to toggle. +// Usage: then +// set ItemsSource in code or by binding. +public class MultiSelect : Border +{ + public static readonly StyledProperty?> ItemsSourceProperty = + AvaloniaProperty.Register?>(nameof(ItemsSource)); + + public static readonly StyledProperty?> ValuesProperty = + AvaloniaProperty.Register?>(nameof(Values), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), ""Select...""); + + public static readonly StyledProperty SearchPlaceholderProperty = + AvaloniaProperty.Register(nameof(SearchPlaceholder), ""Search...""); + + public static readonly StyledProperty EmptyTextProperty = + AvaloniaProperty.Register(nameof(EmptyText), ""No results found.""); + + private const double ItemHeight = 32; + private const double MaxListHeight = 240; + + private readonly Border _trigger; + private readonly WrapLayout _chips; + private readonly TextBlock _placeholder; + private readonly Border _panel; + private readonly TextBox _search; + private readonly StackPanel _list = new(); + private readonly Panel _listArea; + private readonly TextBlock _empty; + private readonly List<(string Text, Border Row, Icon Check)> _rows = new(); + private readonly ShellAnchoredPopup _popup; + private int _active = -1; + + static MultiSelect() + { + ItemsSourceProperty.Changed.AddClassHandler((m, _) => m.RebuildItems()); + ValuesProperty.Changed.AddClassHandler((m, e) => m.OnValuesReplaced(e.OldValue, e.NewValue)); + PlaceholderProperty.Changed.AddClassHandler((m, e) => m._placeholder.Text = (string?)e.NewValue); + SearchPlaceholderProperty.Changed.AddClassHandler((m, e) => m._search.PlaceholderText = (string?)e.NewValue); + EmptyTextProperty.Changed.AddClassHandler((m, e) => m._empty.Text = (string?)e.NewValue); + IsEnabledProperty.Changed.AddClassHandler((m, _) => m.Opacity = m.IsEnabled ? 1.0 : 0.5); + } + + public IList? ItemsSource + { + get => GetValue(ItemsSourceProperty); + set => SetValue(ItemsSourceProperty, value); + } + + public IList? Values + { + get => GetValue(ValuesProperty); + set => SetValue(ValuesProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public string? SearchPlaceholder + { + get => GetValue(SearchPlaceholderProperty); + set => SetValue(SearchPlaceholderProperty, value); + } + + public string? EmptyText + { + get => GetValue(EmptyTextProperty); + set => SetValue(EmptyTextProperty, value); + } + + public bool IsOpen => _popup.IsOpen; + + public event EventHandler>? ValuesChanged; + + public MultiSelect() + { + _placeholder = new TextBlock { Text = Placeholder, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(4, 0, 0, 0) }; + _placeholder.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _chips = new WrapLayout { Spacing = 4, LineSpacing = 4, VerticalAlignment = VerticalAlignment.Center }; + var chevrons = new Icon { Kind = IconName.ChevronsUpDown, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(chevrons, 1); + + _trigger = new Border + { + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions(""*,Auto""), + ColumnSpacing = 8, + Children = { new Panel { Children = { _placeholder, _chips } }, chevrons } + }, + MinHeight = 40, + Padding = new Thickness(8, 6, 12, 6), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + _trigger.Token(BorderBrushProperty, ShellToken.Input); + _trigger.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + SetOpen(!IsOpen); + e.Handled = true; + }; + + _search = new TextBox { FontSize = 14, PlaceholderText = SearchPlaceholder, VerticalAlignment = VerticalAlignment.Center, VerticalContentAlignment = VerticalAlignment.Center }; + ShellPlatform.StripNativeChrome(_search); + _search.TextChanged += (_, _) => Filter(); + _search.KeyDown += OnSearchKeyDown; + Grid.SetColumn(_search, 1); + var searchRow = new Border + { + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions(""Auto,*""), + ColumnSpacing = 8, + Children = { new Icon { Kind = IconName.Search, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }, _search } + }, + Height = 40, + Padding = new Thickness(12, 0) + }; + + var divider = new Border { Height = 1 }; + divider.Token(BackgroundProperty, ShellToken.Border); + + _empty = new TextBlock { Text = EmptyText, FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _empty.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + // Fixed height (set in RebuildItems): the panel keeps its size and position while filtering. + _listArea = new Panel { Margin = new Thickness(4), Children = { new ScrollViewer { Content = _list }, _empty } }; + + _panel = new Border + { + Child = new StackPanel { Children = { searchRow, divider, _listArea } }, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + BoxShadow = ShellPopups.PanelShadow() + }; + _panel.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + + _popup = new ShellAnchoredPopup(_trigger); + _popup.Popup.Child = _panel; + _popup.Closed += (_, _) => + { + _trigger.Token(BorderBrushProperty, ShellToken.Input); + // Focus goes back to the trigger instead of being lost with the panel. + if (_search.IsFocused) Focus(); + }; + + Child = new Panel { Children = { _trigger, _popup.Popup } }; + ShellFocus.Ring(this, _trigger); + RebuildItems(); + RebuildChips(); + } + + private IReadOnlyList Current => (IReadOnlyList?)Values?.ToList() ?? Array.Empty(); + + public void SetOpen(bool open) + { + if (open == _popup.IsOpen) return; + if (!open) + { + _popup.Close(); + return; + } + _search.Text = string.Empty; + Filter(); + // The panel matches the trigger's width. + _panel.Width = _trigger.Bounds.Width; + _trigger.Token(BorderBrushProperty, ShellToken.Ring); + _popup.Open(); + Dispatcher.UIThread.Post(() => + { + if (IsOpen) _search.Focus(); + }, DispatcherPriority.Loaded); + } + + public void Close() => SetOpen(false); + + // Adds the option when it isn't picked, removes it when it is. + public void Toggle(string text) + { + // A new list each time, so a two-way binding sees the change. + var next = new List(Current); + if (!next.Remove(text)) next.Add(text); + Values = next; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + // Only keys pressed on the closed trigger: the search field handles its own. + if (IsOpen || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space or Key.Down)) return; + SetOpen(true); + e.Handled = true; + } + + private void OnSearchKeyDown(object? sender, KeyEventArgs e) + { + if (e.Key == Key.Enter) + { + if (_active >= 0) Toggle(_rows[_active].Text); + } + else if (e.Key == Key.Down) MoveActive(1); + else if (e.Key == Key.Up) MoveActive(-1); + else return; + e.Handled = true; + } + + private void OnValuesReplaced(object? oldValue, object? newValue) + { + if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated; + if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated; + OnValuesMutated(this, null); + } + + private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) + { + RebuildChips(); + UpdateChecks(); + ValuesChanged?.Invoke(this, Current); + } + + private void RebuildChips() + { + _chips.Children.Clear(); + var current = Current; + foreach (var value in current) + _chips.Children.Add(CreateChip(value)); + _placeholder.IsVisible = current.Count == 0; + } + + private Control CreateChip(string value) + { + var label = new TextBlock { Text = value, FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.SecondaryForeground); + var close = new Icon { Kind = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + // 12px icon in a 20px click target. Its clicks stop here, so they don't toggle the panel. + var remove = new Border { Width = 20, Height = 20, Background = Brushes.Transparent, Child = close }; + AutomationProperties.SetName(remove, $""Remove {value}""); + remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive; + remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground; + remove.PointerPressed += (_, e) => e.Handled = true; + remove.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + if (IsEffectivelyEnabled) Toggle(value); + e.Handled = true; + }; + + var chip = new Border + { + Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, Children = { label, remove } }, + Padding = new Thickness(8, 0, 2, 0), + Height = 24, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd) + }; + chip.Token(BackgroundProperty, ShellToken.Secondary); + return chip; + } + + private void RebuildItems() + { + _list.Children.Clear(); + _rows.Clear(); + _active = -1; + var items = ItemsSource; + _listArea.Height = Math.Min(Math.Max(items?.Count ?? 0, 2) * ItemHeight, MaxListHeight); + if (items != null) + { + foreach (var text in items) + _list.Children.Add(CreateItem(text, _rows.Count)); + } + UpdateChecks(); + Filter(); + } + + private Control CreateItem(string text, int index) + { + var check = new Icon { Kind = IconName.Check, Size = 16, Opacity = 0, Token = ShellToken.PopoverForeground, VerticalAlignment = VerticalAlignment.Center }; + var label = new TextBlock { Text = text, FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.PopoverForeground); + Grid.SetColumn(label, 1); + + var item = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""Auto,*""), ColumnSpacing = 8, Children = { check, label } }, + Height = ItemHeight, + Padding = new Thickness(8, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusSm), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + // The pointer and the arrow keys move the same highlight. + item.PointerMoved += (_, _) => SetActive(index); + item.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + Toggle(text); + e.Handled = true; + }; + + _rows.Add((text, item, check)); + return item; + } + + private void UpdateChecks() + { + var current = Current; + // The check keeps its column when hidden, so option labels stay aligned. + foreach (var (text, _, check) in _rows) + check.Opacity = current.Contains(text) ? 1 : 0; + } + + private void Filter() + { + var query = _search.Text?.Trim() ?? string.Empty; + var first = -1; + for (var i = 0; i < _rows.Count; i++) + { + var (text, row, _) = _rows[i]; + row.IsVisible = query.Length == 0 || text.Contains(query, StringComparison.OrdinalIgnoreCase); + if (row.IsVisible && first < 0) first = i; + } + _empty.IsVisible = first < 0; + SetActive(first); + } + + private void MoveActive(int direction) + { + for (var i = _active + direction; i >= 0 && i < _rows.Count; i += direction) + { + if (!_rows[i].Row.IsVisible) continue; + SetActive(i); + _rows[i].Row.BringIntoView(); + return; + } + } + + private void SetActive(int index) + { + if (index == _active) return; + _active = index; + for (var i = 0; i < _rows.Count; i++) + { + var row = _rows[i].Row; + if (i == index) row.Token(BackgroundProperty, ShellToken.Accent); + else + { + row.ClearToken(BackgroundProperty); + row.Background = Brushes.Transparent; + } + } + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs b/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs index 7e1b389..d6a5e21 100644 --- a/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/NumberInputTemplate.cs @@ -219,6 +219,215 @@ protected override void OnPropertyChanged(string? propertyName = null) } } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Globalization; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Number field with − and + buttons — h-10 rounded-md border, the value centered between them. +// The value is clamped to Minimum / Maximum; the buttons dim at the limits. Up and Down step +// the value while the field has focus. +// Usage: +public class NumberInput : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay, + coerce: (o, value) => ((NumberInput)o).Clamp(value)); + + public static readonly StyledProperty MinimumProperty = + AvaloniaProperty.Register(nameof(Minimum)); + + public static readonly StyledProperty MaximumProperty = + AvaloniaProperty.Register(nameof(Maximum)); + + public static readonly StyledProperty StepProperty = + AvaloniaProperty.Register(nameof(Step), 1m); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder)); + + private readonly TextBox _textBox; + private readonly Border _decrease; + private readonly Border _increase; + + static NumberInput() + { + ValueProperty.Changed.AddClassHandler((n, _) => + { + n.UpdateText(); + n.UpdateButtons(); + n.ValueChanged?.Invoke(n, n.Value); + }); + MinimumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); + MaximumProperty.Changed.AddClassHandler((n, _) => n.OnLimitsChanged()); + PlaceholderProperty.Changed.AddClassHandler((n, e) => n._textBox.PlaceholderText = (string?)e.NewValue); + IsEnabledProperty.Changed.AddClassHandler((n, _) => n.Opacity = n.IsEnabled ? 1.0 : 0.5); + } + + public decimal? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public decimal? Minimum + { + get => GetValue(MinimumProperty); + set => SetValue(MinimumProperty, value); + } + + public decimal? Maximum + { + get => GetValue(MaximumProperty); + set => SetValue(MaximumProperty, value); + } + + public decimal Step + { + get => GetValue(StepProperty); + set => SetValue(StepProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public event EventHandler? ValueChanged; + + public NumberInput() + { + _textBox = new TextBox + { + FontSize = 14, + TextAlignment = TextAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + VerticalContentAlignment = VerticalAlignment.Center + }; + ShellPlatform.StripNativeChrome(_textBox); + _textBox.GotFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Ring); + _textBox.LostFocus += (_, _) => + { + this.Token(BorderBrushProperty, ShellToken.Input); + Commit(); + }; + _textBox.KeyDown += (_, e) => + { + if (e.Key == Key.Enter) Commit(); + else if (e.Key == Key.Up) StepBy(1); + else if (e.Key == Key.Down) StepBy(-1); + else return; + e.Handled = true; + }; + Grid.SetColumn(_textBox, 2); + + _decrease = StepButton(IconName.Minus, ""Decrease"", -1); + _increase = StepButton(IconName.Plus, ""Increase"", 1); + Grid.SetColumn(_increase, 4); + + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions(""40,1,*,1,40""), + Children = { _decrease, Divider(1), _textBox, Divider(3), _increase } + }; + Height = 40; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + // The step buttons' hover tint stays inside the rounded corners. + ClipToBounds = true; + this.Token(BorderBrushProperty, ShellToken.Input); + UpdateText(); + UpdateButtons(); + } + + private static Border Divider(int column) + { + var line = new Border(); + line.Token(BackgroundProperty, ShellToken.Input); + Grid.SetColumn(line, column); + return line; + } + + private Border StepButton(IconName icon, string name, int direction) + { + var glyph = new Icon { Kind = icon, Size = 16, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + var button = new Border { Child = glyph, Background = Brushes.Transparent, Cursor = new Cursor(StandardCursorType.Hand) }; + AutomationProperties.SetName(button, name); + button.PointerEntered += (_, _) => + { + if (button.Opacity < 0.9) return; + button.Token(BackgroundProperty, ShellToken.Muted); + glyph.Token = ShellToken.Foreground; + }; + button.PointerExited += (_, _) => + { + button.ClearToken(BackgroundProperty); + button.Background = Brushes.Transparent; + glyph.Token = ShellToken.MutedForeground; + }; + button.PointerPressed += (_, e) => e.Handled = true; + button.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + StepBy(direction); + e.Handled = true; + }; + return button; + } + + private void StepBy(int direction) + { + if (!IsEffectivelyEnabled) return; + Commit(); + Value = (Value ?? Minimum ?? 0) + direction * Step; + } + + private decimal? Clamp(decimal? value) + { + if (value is not { } number) return null; + if (Minimum is { } min && number < min) number = min; + if (Maximum is { } max && number > max) number = max; + return number; + } + + // A typed number is clamped and kept, empty clears the value, anything else is reverted. + private void Commit() + { + var text = _textBox.Text?.Trim(); + if (string.IsNullOrEmpty(text)) Value = null; + else if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.CurrentCulture, out var typed) || + decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out typed)) + Value = typed; + UpdateText(); + } + + private void OnLimitsChanged() + { + CoerceValue(ValueProperty); + UpdateButtons(); + } + + private void UpdateText() + { + var text = Value?.ToString(CultureInfo.CurrentCulture) ?? string.Empty; + if (_textBox.Text != text) _textBox.Text = text; + } + + private void UpdateButtons() + { + _decrease.Opacity = Value is { } v && Minimum is { } min && v <= min ? 0.4 : 1; + _increase.Opacity = Value is { } w && Maximum is { } max && w >= max ? 0.4 : 1; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs b/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs index b3e4fb6..f8f2215 100644 --- a/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/StatCardTemplate.cs @@ -173,6 +173,152 @@ private void Update() } } +public enum StatTrend { Neutral, Up, Down } +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Dashboard metric — rounded-lg border bg-card p-5: a muted title with an optional icon, the +// value in text-2xl bold, a change pill tinted by trend, and an optional description. +// Usage: +public class StatCard : Border +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty ChangeProperty = + AvaloniaProperty.Register(nameof(Change)); + + public static readonly StyledProperty TrendProperty = + AvaloniaProperty.Register(nameof(Trend)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + private readonly TextBlock _title; + private readonly Icon _icon; + private readonly TextBlock _value; + private readonly Panel _pill; + private readonly Border _pillTint; + private readonly TextBlock _change; + private readonly TextBlock _description; + + static StatCard() + { + TitleProperty.Changed.AddClassHandler((c, _) => c.Update()); + ValueProperty.Changed.AddClassHandler((c, _) => c.Update()); + ChangeProperty.Changed.AddClassHandler((c, _) => c.Update()); + TrendProperty.Changed.AddClassHandler((c, _) => c.Update()); + DescriptionProperty.Changed.AddClassHandler((c, _) => c.Update()); + IconProperty.Changed.AddClassHandler((c, _) => c.Update()); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Change + { + get => GetValue(ChangeProperty); + set => SetValue(ChangeProperty, value); + } + + public StatTrend Trend + { + get => GetValue(TrendProperty); + set => SetValue(TrendProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public StatCard() + { + _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_icon, 1); + var header = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,Auto""), ColumnSpacing = 8, Children = { _title, _icon } }; + + _value = new TextBlock { FontSize = 24, FontWeight = FontWeight.Bold, VerticalAlignment = VerticalAlignment.Center }; + _value.Token(TextBlock.ForegroundProperty, ShellToken.CardForeground); + + // The pill is the trend color at low strength behind text in the full color. + _pillTint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; + _change = new TextBlock { FontSize = 12, FontWeight = FontWeight.Medium, Margin = new Thickness(6, 2), VerticalAlignment = VerticalAlignment.Center }; + _pill = new Panel { VerticalAlignment = VerticalAlignment.Center, Children = { _pillTint, _change } }; + + _description = new TextBlock { FontSize = 12, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 0) }; + _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + Child = new StackPanel + { + Children = + { + header, + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, Margin = new Thickness(0, 8, 0, 0), Children = { _value, _pill } }, + _description + } + }; + Padding = new Thickness(20); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + this.Token(BackgroundProperty, ShellToken.Card).Token(BorderBrushProperty, ShellToken.Border); + Update(); + } + + private void Update() + { + _title.Text = Title ?? string.Empty; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _value.Text = Value ?? string.Empty; + + _change.Text = Change ?? string.Empty; + _pill.IsVisible = !string.IsNullOrEmpty(Change); + var (tint, strength, text) = Trend switch + { + StatTrend.Up => (ShellToken.Success, 0.12, ShellToken.Success), + StatTrend.Down => (ShellToken.Destructive, 0.12, ShellToken.Destructive), + _ => (ShellToken.Muted, 1.0, ShellToken.MutedForeground) + }; + _pillTint.Token(BackgroundProperty, tint); + _pillTint.Opacity = strength; + _change.Token(TextBlock.ForegroundProperty, text); + + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + } +} + public enum StatTrend { Neutral, Up, Down } " }; diff --git a/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs b/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs index a381dcb..406457e 100644 --- a/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TagInputTemplate.cs @@ -235,6 +235,213 @@ protected override void OnPropertyChanged(string? propertyName = null) } } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Tag field — min-h-10 rounded-md border holding chips (rounded-md bg-secondary text-xs) and a +// text field that wraps with them. Enter, a comma or a semicolon turns what was typed into a +// tag; the × on a chip removes it, and Backspace in the empty field removes the last one. +// Usage: +public class TagInput : Border +{ + public static readonly StyledProperty?> TagsProperty = + AvaloniaProperty.Register?>(nameof(Tags), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty PlaceholderProperty = + AvaloniaProperty.Register(nameof(Placeholder), ""Add tag...""); + + // 0 means no limit. + public static readonly StyledProperty MaxTagsProperty = + AvaloniaProperty.Register(nameof(MaxTags)); + + public static readonly StyledProperty AllowDuplicatesProperty = + AvaloniaProperty.Register(nameof(AllowDuplicates)); + + private static readonly char[] Separators = { ',', ';', '\n', '\t' }; + + private readonly WrapLayout _flow; + private readonly TextBox _textBox; + + static TagInput() + { + TagsProperty.Changed.AddClassHandler((t, e) => t.OnTagsReplaced(e.OldValue, e.NewValue)); + PlaceholderProperty.Changed.AddClassHandler((t, _) => t.UpdatePlaceholder()); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.Opacity = t.IsEnabled ? 1.0 : 0.5); + } + + public IList? Tags + { + get => GetValue(TagsProperty); + set => SetValue(TagsProperty, value); + } + + public string? Placeholder + { + get => GetValue(PlaceholderProperty); + set => SetValue(PlaceholderProperty, value); + } + + public int MaxTags + { + get => GetValue(MaxTagsProperty); + set => SetValue(MaxTagsProperty, value); + } + + public bool AllowDuplicates + { + get => GetValue(AllowDuplicatesProperty); + set => SetValue(AllowDuplicatesProperty, value); + } + + public event EventHandler>? TagsChanged; + + public TagInput() + { + _textBox = new TextBox + { + FontSize = 14, + Height = 28, + Margin = new Thickness(2), + VerticalContentAlignment = VerticalAlignment.Center + }; + ShellPlatform.StripNativeChrome(_textBox); + // Tunnel, so Backspace is seen before the text box handles it. + _textBox.AddHandler(KeyDownEvent, (_, e) => + { + if (e.Key == Key.Enter) + { + AddFromText(); + e.Handled = true; + } + else if (e.Key == Key.Back && string.IsNullOrEmpty(_textBox.Text) && Current.Count > 0) + { + Remove(Current[^1]); + e.Handled = true; + } + }, RoutingStrategies.Tunnel); + _textBox.TextChanged += (_, _) => + { + // Typing or pasting a separator commits everything before it. + if (_textBox.Text?.IndexOfAny(Separators) >= 0) AddFromText(); + }; + _textBox.GotFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Ring); + _textBox.LostFocus += (_, _) => this.Token(BorderBrushProperty, ShellToken.Input); + + _flow = new WrapLayout { Spacing = 0, LineSpacing = 0, LastChildFill = true, VerticalAlignment = VerticalAlignment.Center, Children = { _textBox } }; + + Child = _flow; + MinHeight = 40; + Padding = new Thickness(8, 3); + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Ibeam); + this.Token(BorderBrushProperty, ShellToken.Input); + UpdatePlaceholder(); + } + + // Clicking the empty part of the field focuses the text box. + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (IsEffectivelyEnabled && !_textBox.IsFocused) _textBox.Focus(); + } + + private IReadOnlyList Current => (IReadOnlyList?)Tags?.ToList() ?? Array.Empty(); + + public void Add(string tag) + { + var text = tag.Trim(); + if (text.Length == 0) return; + var current = Current; + if (MaxTags > 0 && current.Count >= MaxTags) return; + if (!AllowDuplicates && current.Contains(text, StringComparer.OrdinalIgnoreCase)) return; + // A new list each time, so a two-way binding sees the change. + Tags = new List(current) { text }; + } + + public void Remove(string tag) + { + var next = new List(Current); + if (next.Remove(tag)) Tags = next; + } + + private void AddFromText() + { + var text = _textBox.Text ?? string.Empty; + _textBox.Text = string.Empty; + foreach (var part in text.Split(Separators, StringSplitOptions.RemoveEmptyEntries)) + Add(part); + } + + private void OnTagsReplaced(object? oldValue, object? newValue) + { + if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnTagsMutated; + if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnTagsMutated; + OnTagsMutated(this, null); + } + + private void OnTagsMutated(object? sender, NotifyCollectionChangedEventArgs? e) + { + Rebuild(); + TagsChanged?.Invoke(this, Current); + } + + private void Rebuild() + { + _flow.Children.RemoveAll(_flow.Children.Where(c => c != _textBox).ToList()); + var index = 0; + foreach (var tag in Current) + _flow.Children.Insert(index++, CreateChip(tag)); + UpdatePlaceholder(); + } + + private Control CreateChip(string tag) + { + var label = new TextBlock { Text = tag, FontSize = 12, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + label.Token(TextBlock.ForegroundProperty, ShellToken.SecondaryForeground); + var close = new Icon { Kind = IconName.X, Size = 12, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + // 12px icon in a 20px click target. + var remove = new Border { Width = 20, Height = 20, Background = Brushes.Transparent, Child = close, Cursor = new Cursor(StandardCursorType.Hand) }; + AutomationProperties.SetName(remove, $""Remove {tag}""); + remove.PointerEntered += (_, _) => close.Token = ShellToken.Destructive; + remove.PointerExited += (_, _) => close.Token = ShellToken.MutedForeground; + remove.PointerPressed += (_, e) => e.Handled = true; + remove.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + if (IsEffectivelyEnabled) Remove(tag); + e.Handled = true; + }; + + var chip = new Border + { + Child = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 2, Children = { label, remove } }, + Padding = new Thickness(8, 0, 2, 0), + Height = 24, + Margin = new Thickness(2, 3), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Cursor = new Cursor(StandardCursorType.Arrow) + }; + chip.Token(BackgroundProperty, ShellToken.Secondary); + return chip; + } + + private void UpdatePlaceholder() => _textBox.PlaceholderText = Current.Count == 0 ? Placeholder : null; +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TimelineTemplate.cs b/src/ShellUI.Native.Templates/Templates/TimelineTemplate.cs index e97169f..6cf344d 100644 --- a/src/ShellUI.Native.Templates/Templates/TimelineTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TimelineTemplate.cs @@ -237,6 +237,223 @@ private void Update() _body.IsVisible = Body != null; } } +", + [NativePlatform.Avalonia] = @"using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// Vertical timeline — markers joined by a hairline, each with a title, a time and details. +// +// +// +// +// +// +public class Timeline : Border +{ + private readonly StackPanel _stack = new(); + + [Content] + public Controls Items => _stack.Children; + + public Timeline() + { + _stack.Children.CollectionChanged += (_, _) => Refresh(); + Child = _stack; + } + + // The timeline tells each item if it is first or last, so the line starts and stops at a marker. + private void Refresh() + { + var items = _stack.Children.OfType().ToList(); + for (var i = 0; i < items.Count; i++) + items[i].Place(isFirst: i == 0, isLast: i == items.Count - 1); + } +} + +// One entry of a Timeline. Without an Icon the marker is a dot (ringed when active); with one it +// is a 28px circle around the icon. A child control goes under the text. +public class TimelineItem : Border +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty TimeProperty = + AvaloniaProperty.Register(nameof(Time)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IsActiveProperty = + AvaloniaProperty.Register(nameof(IsActive)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + private const double MarkerSize = 28; + + private readonly Border _line; + private readonly Border _ring; + private readonly Border _dot; + private readonly Border _badge; + private readonly Icon _icon; + private readonly TextBlock _title; + private readonly TextBlock _time; + private readonly TextBlock _text; + private readonly Border _body; + private readonly StackPanel _details; + + static TimelineItem() + { + TitleProperty.Changed.AddClassHandler((i, _) => i.Update()); + TimeProperty.Changed.AddClassHandler((i, _) => i.Update()); + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsActiveProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + BodyProperty.Changed.AddClassHandler((i, _) => i.Update()); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Time + { + get => GetValue(TimeProperty); + set => SetValue(TimeProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsActive + { + get => GetValue(IsActiveProperty); + set => SetValue(IsActiveProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public TimelineItem() + { + _line = new Border { Width = 1, HorizontalAlignment = HorizontalAlignment.Center }; + _line.Token(BackgroundProperty, ShellToken.Border); + + // ring-4 ring-primary/20 around the active dot. + _ring = new Border { Width = 18, Height = 18, CornerRadius = new CornerRadius(9), Opacity = 0.2, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _ring.Token(BackgroundProperty, ShellToken.Primary); + _dot = new Border { Width = 10, Height = 10, CornerRadius = new CornerRadius(5), HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _icon = new Icon { Size = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _badge = new Border + { + Child = _icon, + Width = MarkerSize, + Height = MarkerSize, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(MarkerSize / 2) + }; + _badge.Token(BackgroundProperty, ShellToken.Background); + var marker = new Panel + { + Width = MarkerSize, + Height = MarkerSize, + VerticalAlignment = VerticalAlignment.Top, + Children = { _ring, _dot, _badge } + }; + + _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _time = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center }; + _time.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _text = new TextBlock { FontSize = 14, TextWrapping = TextWrapping.Wrap }; + _text.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + _body = new Border(); + _details = new StackPanel + { + Spacing = 2, + Children = + { + new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, MinHeight = MarkerSize, Children = { _title, _time } }, + _text, + _body + } + }; + Grid.SetColumn(_details, 1); + + Child = new Grid + { + ColumnDefinitions = new ColumnDefinitions(""28,*""), + ColumnSpacing = 12, + Children = { _line, marker, _details } + }; + Place(isFirst: true, isLast: true); + Update(); + } + + internal void Place(bool isFirst, bool isLast) + { + // The line runs through the marker's center: from it down (first), up to it (last), or + // the full height (in between). A lone item has no line. + _line.IsVisible = !(isFirst && isLast); + if (isLast) + { + _line.VerticalAlignment = VerticalAlignment.Top; + _line.Height = MarkerSize / 2; + _line.Margin = new Thickness(0); + } + else + { + _line.VerticalAlignment = VerticalAlignment.Stretch; + _line.Height = double.NaN; + _line.Margin = new Thickness(0, isFirst ? MarkerSize / 2 : 0, 0, 0); + } + _details.Margin = new Thickness(0, 0, 0, isLast ? 0 : 24); + } + + private void Update() + { + var hasIcon = Icon != IconName.None; + _badge.IsVisible = hasIcon; + _dot.IsVisible = !hasIcon; + _ring.IsVisible = !hasIcon && IsActive; + _icon.Kind = Icon; + _icon.Token = IsActive ? ShellToken.Primary : ShellToken.MutedForeground; + _badge.Token(BorderBrushProperty, IsActive ? ShellToken.Primary : ShellToken.Border); + _dot.Token(BackgroundProperty, IsActive ? ShellToken.Primary : ShellToken.MutedForeground); + + _title.Text = Title ?? string.Empty; + _time.Text = Time ?? string.Empty; + _time.IsVisible = !string.IsNullOrEmpty(Time); + _text.Text = Text ?? string.Empty; + _text.IsVisible = !string.IsNullOrEmpty(Text); + _body.Child = Body; + _body.IsVisible = Body != null; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs index f7cfa52..bf10891 100644 --- a/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ToggleGroupTemplate.cs @@ -260,6 +260,270 @@ private void Update() } } +public enum ToggleGroupVariant { Default, Outline } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// A row of toggles that work as one control: pick one (default) or several (Multiple). +// +// +// +// +// ... +public class ToggleGroup : Border +{ + public static readonly StyledProperty MultipleProperty = + AvaloniaProperty.Register(nameof(Multiple)); + + // The pressed item's Value in single mode; null when nothing is pressed. + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + // The pressed items' Values in Multiple mode. + public static readonly StyledProperty?> ValuesProperty = + AvaloniaProperty.Register?>(nameof(Values), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + + private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 }; + + static ToggleGroup() + { + MultipleProperty.Changed.AddClassHandler((g, _) => g.Refresh()); + VariantProperty.Changed.AddClassHandler((g, _) => g.Refresh()); + ValueProperty.Changed.AddClassHandler((g, _) => + { + g.Refresh(); + g.ValueChanged?.Invoke(g, g.Value); + }); + ValuesProperty.Changed.AddClassHandler((g, e) => g.OnValuesReplaced(e.OldValue, e.NewValue)); + } + + public bool Multiple + { + get => GetValue(MultipleProperty); + set => SetValue(MultipleProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IList? Values + { + get => GetValue(ValuesProperty); + set => SetValue(ValuesProperty, value); + } + + public ToggleGroupVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + [Content] + public Controls Items => _row.Children; + + public event EventHandler? ValueChanged; + public event EventHandler>? ValuesChanged; + + public ToggleGroup() + { + _row.Children.CollectionChanged += (_, _) => Refresh(); + Child = _row; + HorizontalAlignment = HorizontalAlignment.Left; + } + + public bool IsPressed(string? value) => + value != null && (Multiple ? Values?.Contains(value) == true : Value == value); + + internal void Toggle(string? value) + { + if (value is null || !IsEffectivelyEnabled) return; + if (Multiple) + { + // A new list each time, so a two-way binding sees the change. + var next = new List(Values ?? Array.Empty()); + if (!next.Remove(value)) next.Add(value); + Values = next; + } + else + { + Value = Value == value ? null : value; + } + } + + private void OnValuesReplaced(object? oldValue, object? newValue) + { + if (oldValue is INotifyCollectionChanged before) before.CollectionChanged -= OnValuesMutated; + if (newValue is INotifyCollectionChanged after) after.CollectionChanged += OnValuesMutated; + OnValuesMutated(this, null); + } + + private void OnValuesMutated(object? sender, NotifyCollectionChangedEventArgs? e) + { + Refresh(); + ValuesChanged?.Invoke(this, (Values ?? Array.Empty()).ToList()); + } + + // Items don't track the group's state: the group pushes it to them. + private void Refresh() + { + foreach (var item in _row.Children.OfType()) + item.Apply(IsPressed(item.Value), Variant == ToggleGroupVariant.Outline); + } +} + +// One toggle in a ToggleGroup — h-9 min-w-9 rounded-md px-2.5, bg-accent while pressed. A tab +// stop that toggles with Enter or Space. +public class ToggleGroupItem : Border, IShellFocusable +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private bool _pressed; + private bool _outline; + + static ToggleGroupItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update()); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public ToggleGroupItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 8, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { _icon, _label } + }; + Height = 36; + MinWidth = 36; + Padding = new Thickness(10, 0); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space)) return; + Press(); + e.Handled = true; + } + + private void Press() + { + if (IsEffectivelyEnabled) this.FindParentOfType()?.Toggle(Value); + } + + internal void Apply(bool pressed, bool outline) + { + _pressed = pressed; + _outline = outline; + Update(); + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _label.IsVisible = !string.IsNullOrEmpty(Text); + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + AutomationProperties.SetName(this, string.IsNullOrEmpty(Text) ? Icon.ToString() : Text); + + BorderThickness = new Thickness(_outline ? 1 : 0); + this.Token(BorderBrushProperty, ShellToken.Input); + + var hovered = IsPointerOver && IsEffectivelyEnabled; + var foreground = _pressed ? ShellToken.AccentForeground + : hovered ? ShellToken.MutedForeground + : ShellToken.Foreground; + _label.Token(TextBlock.ForegroundProperty, foreground); + _icon.Token = foreground; + + if (_pressed) this.Token(BackgroundProperty, ShellToken.Accent); + else if (hovered) this.Token(BackgroundProperty, ShellToken.Muted); + else + { + // Transparent, not null, so the whole toggle stays hit-testable. + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + Opacity = IsEnabled ? 1.0 : 0.5; + } +} + public enum ToggleGroupVariant { Default, Outline } " }; diff --git a/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs b/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs index 08cf733..cf24993 100644 --- a/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TreeViewTemplate.cs @@ -235,6 +235,225 @@ protected override void OnPropertyChanged(string? propertyName = null) Opacity = IsEnabled ? 1.0 : 0.5; } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// Tree of expandable rows — rounded-md px-2 py-1.5 text-sm rows, hover:bg-muted, bg-accent when +// selected; children are indented behind a hairline. Clicking a row selects it and, when it has +// children, expands or collapses it. Rows are tab stops: Enter / Space act like a click, Right +// expands and Left collapses. +// +// +// +// +// +public class TreeView : Border +{ + // The selected item's Value (its Text when no Value is set). + public static readonly StyledProperty SelectedValueProperty = + AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay); + + private readonly StackPanel _stack = new() { Spacing = 2 }; + + static TreeView() + { + SelectedValueProperty.Changed.AddClassHandler((t, _) => + { + t.Refresh(); + t.SelectedValueChanged?.Invoke(t, t.SelectedValue); + }); + } + + public string? SelectedValue + { + get => GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + [Content] + public Controls Items => _stack.Children; + + public event EventHandler? SelectedValueChanged; + + public TreeView() + { + Child = _stack; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(); + } + + // Items don't track the selection: the tree pushes it to every item under it. + private void Refresh() + { + foreach (var item in this.FindDescendantsOfType()) + item.ApplySelection(SelectedValue); + } +} + +// One row of a TreeView. Nested TreeViewItems become its children. +public class TreeViewItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // Identifies the item for selection. Defaults to Text. + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + public static readonly StyledProperty IsExpandedProperty = + AvaloniaProperty.Register(nameof(IsExpanded), defaultBindingMode: BindingMode.TwoWay); + + private readonly Border _row; + private readonly Icon _chevron; + private readonly Icon _icon; + private readonly TextBlock _label; + private readonly Panel _childrenHost; + private readonly StackPanel _children = new() { Spacing = 2, Margin = new Thickness(25, 0, 0, 0) }; + private bool _selected; + + static TreeViewItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsExpandedProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Opacity = i.IsEnabled ? 1.0 : 0.5); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public bool IsExpanded + { + get => GetValue(IsExpandedProperty); + set => SetValue(IsExpandedProperty, value); + } + + [Content] + public Controls Items => _children.Children; + + public string EffectiveValue => Value ?? Text ?? string.Empty; + + public TreeViewItem() + { + _chevron = new Icon { Kind = IconName.ChevronRight, Size = 16, Token = ShellToken.MutedForeground }; + _icon = new Icon { Size = 16, Token = ShellToken.MutedForeground, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_icon, 1); + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + Grid.SetColumn(_label, 2); + + // The chevron keeps its 16px slot on leaves, so labels line up within a level. + var slot = new Panel { Width = 16, Height = 16, VerticalAlignment = VerticalAlignment.Center, Children = { _chevron } }; + _row = new Border + { + Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""Auto,Auto,*""), ColumnSpacing = 6, Children = { slot, _icon, _label } }, + Height = 32, + Padding = new Thickness(8, 0), + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent, + Cursor = new Cursor(StandardCursorType.Hand) + }; + ShellFocus.Ring(_row, _row); + _row.PointerEntered += (_, _) => Paint(); + _row.PointerExited += (_, _) => Paint(); + _row.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + }; + _row.KeyDown += OnRowKeyDown; + + // ml-4 border-l pl-2: a hairline under the parent's chevron, children to its right. + var guide = new Border { Width = 1, HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(16, 0, 0, 0) }; + guide.Token(BackgroundProperty, ShellToken.Border); + _children.Children.CollectionChanged += (_, _) => Update(); + _childrenHost = new Panel { Margin = new Thickness(0, 2, 0, 0), IsVisible = false, Children = { guide, _children } }; + + Child = new StackPanel { Children = { _row, _childrenHost } }; + Update(); + } + + private bool HasChildren => _children.Children.Count > 0; + + private void OnRowKeyDown(object? sender, KeyEventArgs e) + { + if (!IsEffectivelyEnabled) return; + if (e.Key is Key.Enter or Key.Space) Press(); + else if (e.Key == Key.Right && HasChildren && !IsExpanded) IsExpanded = true; + else if (e.Key == Key.Left && HasChildren && IsExpanded) IsExpanded = false; + else return; + e.Handled = true; + } + + private void Press() + { + if (!IsEffectivelyEnabled) return; + if (HasChildren) IsExpanded = !IsExpanded; + if (this.FindParentOfType() is { } tree) tree.SelectedValue = EffectiveValue; + } + + internal void ApplySelection(string? selected) + { + _selected = selected != null && selected == EffectiveValue; + Paint(); + } + + private void Update() + { + _label.Text = Text ?? string.Empty; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _chevron.IsVisible = HasChildren; + _chevron.Kind = IsExpanded ? IconName.ChevronDown : IconName.ChevronRight; + _childrenHost.IsVisible = HasChildren && IsExpanded; + Paint(); + } + + private void Paint() + { + _label.FontWeight = _selected ? FontWeight.Medium : FontWeight.Normal; + _label.Token(TextBlock.ForegroundProperty, _selected ? ShellToken.AccentForeground : ShellToken.Foreground); + if (_selected) _row.Token(BackgroundProperty, ShellToken.Accent); + else if (_row.IsPointerOver && IsEffectivelyEnabled) _row.Token(BackgroundProperty, ShellToken.Muted); + else + { + // Transparent, not null, so the whole row stays hit-testable. + _row.ClearToken(BackgroundProperty); + _row.Background = Brushes.Transparent; + } + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/WrapLayoutTemplate.cs b/src/ShellUI.Native.Templates/Templates/WrapLayoutTemplate.cs index 22599b5..8ba559f 100644 --- a/src/ShellUI.Native.Templates/Templates/WrapLayoutTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/WrapLayoutTemplate.cs @@ -162,6 +162,143 @@ void EndRow() } } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using Avalonia; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// flex flex-wrap gap-2 — children flow left to right and wrap onto new rows, centered vertically +// within their row. Use it for button rows, chips and tag lists. +// +// ... +// +// Unlike WrapPanel, the last child can take the rest of its row (LastChildFill). +public class WrapLayout : Panel +{ + // Gap between children in a row. + public static readonly StyledProperty SpacingProperty = + AvaloniaProperty.Register(nameof(Spacing), 8); + + // Gap between rows. + public static readonly StyledProperty LineSpacingProperty = + AvaloniaProperty.Register(nameof(LineSpacing), 8); + + // The last child takes what is left of its row (or a row of its own when less than + // MinimumLastChildWidth remains) — e.g. the text field after a run of chips. + public static readonly StyledProperty LastChildFillProperty = + AvaloniaProperty.Register(nameof(LastChildFill)); + + public static readonly StyledProperty MinimumLastChildWidthProperty = + AvaloniaProperty.Register(nameof(MinimumLastChildWidth), 96); + + static WrapLayout() + { + AffectsMeasure(SpacingProperty, LineSpacingProperty, LastChildFillProperty, MinimumLastChildWidthProperty); + } + + public double Spacing + { + get => GetValue(SpacingProperty); + set => SetValue(SpacingProperty, value); + } + + public double LineSpacing + { + get => GetValue(LineSpacingProperty); + set => SetValue(LineSpacingProperty, value); + } + + public bool LastChildFill + { + get => GetValue(LastChildFillProperty); + set => SetValue(LastChildFillProperty, value); + } + + public double MinimumLastChildWidth + { + get => GetValue(MinimumLastChildWidthProperty); + set => SetValue(MinimumLastChildWidthProperty, value); + } + + protected override Size MeasureOverride(Size availableSize) => Flow(availableSize.Width, arrange: false); + + protected override Size ArrangeOverride(Size finalSize) + { + Flow(finalSize.Width, arrange: true); + return finalSize; + } + + private Size Flow(double available, bool arrange) + { + var bounded = !double.IsInfinity(available); + var row = new List<(Control Child, double X, double Width, double Height)>(); + double x = 0, y = 0, rowHeight = 0, widest = 0; + + var last = -1; + for (var i = Children.Count - 1; i >= 0 && last < 0; i--) + if (Children[i].IsVisible) last = i; + + void EndRow() + { + if (arrange) + { + foreach (var (child, left, width, height) in row) + child.Arrange(new Rect(left, y + (rowHeight - height) / 2, width, height)); + } + row.Clear(); + y += rowHeight + LineSpacing; + x = 0; + rowHeight = 0; + } + + for (var i = 0; i <= last; i++) + { + var child = Children[i]; + if (!child.IsVisible) continue; + + var start = row.Count > 0 ? x + Spacing : 0; + double width, height; + if (LastChildFill && i == last) + { + var rest = bounded ? available - start : MinimumLastChildWidth; + if (bounded && rest < MinimumLastChildWidth && row.Count > 0) + { + EndRow(); + start = 0; + rest = available; + } + width = Math.Max(0, rest); + if (!arrange) child.Measure(new Size(width, double.PositiveInfinity)); + height = child.DesiredSize.Height; + } + else + { + if (!arrange) child.Measure(new Size(available, double.PositiveInfinity)); + var size = child.DesiredSize; + width = bounded ? Math.Min(size.Width, available) : size.Width; + height = size.Height; + // Half a pixel of slack so rounding can't wrap differently between passes. + if (bounded && row.Count > 0 && start + width > available + 0.5) + { + EndRow(); + start = 0; + } + } + + row.Add((child, start, width, height)); + x = start + width; + rowHeight = Math.Max(rowHeight, height); + widest = Math.Max(widest, x); + } + + var total = y + rowHeight; + if (row.Count > 0) EndRow(); + return new Size(widest, last < 0 ? 0 : total); + } +} " }; } diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 3378a42..6b91d1f 100644 --- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs +++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs @@ -20,7 +20,7 @@ public static IEnumerable AvaloniaComponents() .Where(name => ComponentRegistry.SupportsPlatform(name, NativePlatform.Avalonia)) .Select(name => new object[] { name }); - // Ported so far: the Phase 2 foundation, P0 to P5 and three of P6. Grows as components are ported. + // Every component registered so far. A new MAUI component is added here once it is ported. [Theory] [InlineData("shell")] [InlineData("icon")] @@ -100,6 +100,20 @@ public static IEnumerable AvaloniaComponents() [InlineData("context-menu")] [InlineData("carousel")] [InlineData("stepper")] + [InlineData("toggle-group")] + [InlineData("number-input")] + [InlineData("tag-input")] + [InlineData("kbd")] + [InlineData("stat-card")] + [InlineData("timeline")] + [InlineData("multi-select")] + [InlineData("tree-view")] + [InlineData("copy-button")] + [InlineData("link-card")] + [InlineData("aspect-ratio")] + [InlineData("wrap-layout")] + [InlineData("combobox")] + [InlineData("input-otp")] public void Ported_components_have_Avalonia_content(string name) { Assert.False(string.IsNullOrWhiteSpace(ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia)));