diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index ed15b90..9de62b3 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -69,15 +69,17 @@ your background so the splash-to-app transition doesn't flash the template's pur 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) and P2 (`textarea`, `select`, `slider`, -`radio-group`, `calendar`, `date-picker`, `time-picker`). Not yet published to NuGet. In an +`drawer`, `sheet`, `dropdown`, `popover` and their parts), P2 (`textarea`, `select`, `slider`, +`radio-group`, `calendar`, `date-picker`, `time-picker`) and P3 (`collapsible`, `accordion`, +`tabs`, `breadcrumb` and their parts, `skeleton`, `scroll-area`). Not yet published to NuGet. In an Avalonia project, `shellui-native list` shows only these. 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 Avalonia's placement and flip above the trigger when there is no room below, and a click outside -closes them. Escape closes the overlay on top. Keyboard focus is not trapped inside an open -dialog yet: Tab can still reach controls behind it. +closes them. Escape closes the overlay on top. An open dialog, drawer or sheet keeps keyboard +focus inside it: focus moves to its first control, Tab and Shift+Tab wrap around, and on close +focus returns to the control that opened it. Setup after `shellui-native init`: call `Components.UI.ShellTheme.EnsureInitialized();` in `App.Initialize` after `AvaloniaXamlLoader.Load(this);`, and add @@ -93,11 +95,13 @@ Same names, properties and variants as MAUI, except: | Card children | XAML children | XAML children, collected in `Card.Items` | | Block button | `HorizontalOptions="Fill"` | `HorizontalAlignment="Stretch"` | | Focus ring | Platform focus visuals | shadcn's ring, for keyboard focus only | -| Overlay children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, …) | +| Part children | XAML children | XAML children, collected in `Items` (`Dialog`, `DialogContent`, `Dropdown`, `AccordionItem`, `TabsList`, …) | | Dropdown / Popover | `ToggleAsync()` / `CloseAsync()` kept for older code | `Toggle()` / `Close()` only | -| Flipped popup gap | 4px above the trigger | Flush with the trigger (Avalonia doesn't mirror the offset) | | Slider | Platform slider tinted with tokens | Custom-drawn shadcn slider; adds `Step` for the arrow keys; `ValueChanged` passes the new `double` | | DatePicker `DateChanged` | `DateChangedEventArgs` | The new `DateTime` | +| Tabs keyboard | Tab and Enter | Also Left / Right to select the neighbouring tab | +| Skeleton `CornerRadius` | `double` | Border's `CornerRadius` (`CornerRadius="24"` still works) | +| ScrollArea scrollbar | Platform scrollbar | Fluent's thin scrollbar, hidden while idle | --- diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index b88f185..910d241 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 merged | 92 registry entries (89 CLI targets, 58 families), MAUI only | -| `chore/release-v0.1.0-alpha.1` | `main` | **Active** | Version `0.1.0-alpha.1`, fixed release workflow, release notes, docs refresh, per-component CLI check | -| `feat/avalonia-implementation` | `main` | Next | Phase 2 | +| `main` | — | Phase 1a–1e, release `0.1.0-alpha.1`, Phase 2 foundation and P0–P2 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P2 | +| `feat/avalonia-p3-navigation` | `main` | **Active** | Phase 2 P3: Avalonia navigation and layout | --- @@ -418,7 +417,7 @@ and TableCell) rather than one template per part. --- -## Release `0.1.0-alpha.1` — `chore/release-v0.1.0-alpha.1` (active) +## Release `0.1.0-alpha.1` — `chore/release-v0.1.0-alpha.1` (**released**; tag `v0.1.0-alpha.1`) First NuGet prerelease of `ShellUI.Native.CLI`, MAUI only. @@ -435,7 +434,7 @@ After merge: tag `v0.1.0-alpha.1` on `main` and push the tag. --- -## Phase 2 — `feat/avalonia-implementation` (next, after `0.1.0-alpha.1`) +## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p3-navigation` now) Cross-desktop (Windows + macOS + Linux) from one XAML codebase. @@ -461,10 +460,18 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. overlay layer (shared hosts in `shell`: `ShellOverlayHost`, `ShellPopoverHost`, `ShellTriggerView`, `ShellDismiss` for Escape). Each installs alone into a fresh `dotnet new avalonia.app`; checked in the demo: opening from triggers, placement and flip, - Escape, picking an item, `DialogClose`, light and dark. Open: focus trapping in dialogs + Escape, picking an item, `DialogClose`, light and dark. Focus trap added on the P3 branch: focus + moves into the content, Tab cycles inside it and returns to the trigger on close (checked with + real key presses); the Dialog and Sheet close buttons are tab stops - [x] P2: textarea, select, slider (custom-drawn), radio-group, calendar, date-picker, time-picker. Pickers float their panel with `ShellAnchoredPopup` (shared with Dropdown and Popover); `ShellPlatform.StripNativeChrome` clears Fluent's TextBox chrome for Input and Textarea +- [x] P3: collapsible, accordion, tabs, breadcrumb (and their parts), skeleton, scroll-area. + Collapsible and accordion content animate their height with `AnimateExpandAsync` in + `element-extensions`, as on MAUI. Each installs alone into a fresh `dotnet new avalonia.app` and + builds with 0 warnings; checked in the demo in light and dark, including keyboard tab switching. + Also on this branch: floating panels keep their 4px gap when they flip above the trigger, and + both demos use the ShellUI Native tile as their app icon (Android: adaptive, grid in the safe zone) - [ ] 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 3c13a7e..dcaf482 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–P2 components; not on NuGet yet | .NET 10.0 (Avalonia 12) | +| Avalonia UI | In progress (Phase 2): theme, icons, P0–P3 components; not on NuGet yet | .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/Assets/icon.ico b/examples/Avalonia.Demo/Assets/icon.ico new file mode 100644 index 0000000..15411a8 Binary files /dev/null and b/examples/Avalonia.Demo/Assets/icon.ico differ diff --git a/examples/Avalonia.Demo/AvaloniaDemo.csproj b/examples/Avalonia.Demo/AvaloniaDemo.csproj index fa533e9..387f97a 100644 --- a/examples/Avalonia.Demo/AvaloniaDemo.csproj +++ b/examples/Avalonia.Demo/AvaloniaDemo.csproj @@ -6,8 +6,14 @@ disable app.manifest + + Assets\icon.ico + + + + diff --git a/examples/Avalonia.Demo/Components/UI/Accordion.cs b/examples/Avalonia.Demo/Components/UI/Accordion.cs new file mode 100644 index 0000000..d3245d0 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Accordion.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +public enum AccordionType { Single, Multiple } + +// Stacked expandable sections with dividers and rotating chevrons. +// +// +// +// +// +// +public class Accordion : StackPanel +{ + public static readonly StyledProperty TypeProperty = + AvaloniaProperty.Register(nameof(Type)); + + // Initially open item (Single) — or comma-separated items (Multiple). + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly HashSet _openValues = new(); + + static Accordion() + { + ValueProperty.Changed.AddClassHandler((a, _) => a.OnValueChanged()); + } + + public AccordionType Type + { + get => GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // Fired when any item's open state changes: (value, isOpen). + public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + + public Accordion() + { + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public bool IsOpen(string value) => _openValues.Contains(value); + + public void Toggle(string value) + { + var wasOpen = _openValues.Contains(value); + var changed = new List<(string, bool)>(); + + if (Type == AccordionType.Single) + { + foreach (var other in _openValues.Where(v => v != value).ToList()) + { + _openValues.Remove(other); + changed.Add((other, false)); + } + } + + if (wasOpen) _openValues.Remove(value); + else _openValues.Add(value); + changed.Add((value, !wasOpen)); + + Refresh(animate: true); + foreach (var change in changed) ItemToggled?.Invoke(this, change); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + private void OnValueChanged() + { + _openValues.Clear(); + foreach (var v in (Value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + _openValues.Add(v); + if (Type == AccordionType.Single) break; + } + Refresh(animate: IsLoaded); + } + + // The accordion drives its items, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + var items = this.FindDescendantsOfType(e => e is Accordion).ToList(); + for (var i = 0; i < items.Count; i++) + { + items[i].ApplyOpen(_openValues.Contains(items[i].Value ?? string.Empty), animate); + items[i].SetDividerVisible(i < items.Count - 1); + } + } +} diff --git a/examples/Avalonia.Demo/Components/UI/AccordionContent.cs b/examples/Avalonia.Demo/Components/UI/AccordionContent.cs new file mode 100644 index 0000000..b8c91e6 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AccordionContent.cs @@ -0,0 +1,21 @@ +using Avalonia; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Section body — pb-4 text-sm; expands/collapses its height with the item. +public class AccordionContent : Border +{ + public AccordionContent() + { + IsVisible = false; + Opacity = 0; + Padding = new Thickness(0, 0, 0, 16); + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/AccordionItem.cs b/examples/Avalonia.Demo/Components/UI/AccordionItem.cs new file mode 100644 index 0000000..2e8ac08 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AccordionItem.cs @@ -0,0 +1,50 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// One section: trigger + content, with a bottom divider (border-b, none on the last item). +public class AccordionItem : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly StackPanel _stack = new(); + private readonly Border _divider = new() { Height = 1 }; + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + [Content] + public Controls Items => _stack.Children; + + public bool IsOpen { get; private set; } + + public event EventHandler? OpenChanged; + + public AccordionItem() + { + _divider.Token(BackgroundProperty, ShellToken.Border); + Child = new StackPanel { Children = { _stack, _divider } }; + } + + public void Toggle() => this.FindParentOfType()?.Toggle(Value ?? string.Empty); + + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; + + internal void ApplyOpen(bool open, bool animate) + { + var changed = IsOpen != open; + IsOpen = open; + foreach (var trigger in this.FindDescendantsOfType(e => e is AccordionItem)) + trigger.SetOpen(open, animate); + foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) + content.Apply(open, animate); + if (changed) OpenChanged?.Invoke(this, open); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs b/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs new file mode 100644 index 0000000..f0aff14 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/AccordionTrigger.cs @@ -0,0 +1,91 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Transformation; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Section header — flex justify-between py-4 font-medium hover:underline, with a chevron +// that rotates 180° when open. Use Text for a plain title, or put any control inside. +public class AccordionTrigger : ShellTriggerView +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + private readonly Grid _row; + private readonly TextBlock _label; + private readonly Icon _chevron; + + static AccordionTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + BodyProperty.Changed.AddClassHandler((t, e) => + { + t._row.Children.Remove((Control?)e.OldValue ?? t._label); + t._row.Children.Insert(0, t.Body ?? t._label); + }); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public AccordionTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _chevron = new Icon { Kind = IconName.ChevronDown, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_chevron, 1); + + _row = new Grid + { + ColumnDefinitions = new ColumnDefinitions("*,Auto"), + ColumnSpacing = 16, + Margin = new Thickness(0, 16), + Children = { _label, _chevron } + }; + + Child = _row; + HorizontalAlignment = HorizontalAlignment.Stretch; + } + + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + _label.TextDecorations = TextDecorations.Underline; + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + _label.TextDecorations = null; + } + + internal void SetOpen(bool open, bool animate) + { + _chevron.Transitions = animate + ? new Transitions { new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() } } + : null; + _chevron.RenderTransform = TransformOperations.Parse(open ? "rotate(180deg)" : "rotate(0deg)"); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Breadcrumb.cs b/examples/Avalonia.Demo/Components/UI/Breadcrumb.cs new file mode 100644 index 0000000..dc7b920 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Breadcrumb.cs @@ -0,0 +1,26 @@ +using System.Linq; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace AvaloniaDemo.Components.UI; + +// Navigation trail — items separated by chevrons; the last item hides its separator. +// +// +// +// +public class Breadcrumb : StackPanel +{ + public Breadcrumb() + { + Orientation = Orientation.Horizontal; + Children.CollectionChanged += (_, _) => RefreshItemSeparators(); + } + + private void RefreshItemSeparators() + { + var items = Children.OfType().ToList(); + for (var i = 0; i < items.Count; i++) + items[i].SetSeparatorVisible(i < items.Count - 1); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/BreadcrumbItem.cs b/examples/Avalonia.Demo/Components/UI/BreadcrumbItem.cs new file mode 100644 index 0000000..2c1db1e --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/BreadcrumbItem.cs @@ -0,0 +1,83 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Breadcrumb link — text-sm text-muted-foreground hover:text-foreground; the current page is +// text-foreground and not clickable. A chevron separator follows every item but the last. +public class BreadcrumbItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IsCurrentProperty = + AvaloniaProperty.Register(nameof(IsCurrent)); + + private readonly Border _link; + private readonly TextBlock _text; + private readonly Icon _separator; + private bool _hovered; + + static BreadcrumbItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + IsCurrentProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsCurrent + { + get => GetValue(IsCurrentProperty); + set => SetValue(IsCurrentProperty, value); + } + + public event EventHandler? Clicked; + + public BreadcrumbItem() + { + _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + // Takes the hover, click and focus ring, so the separator stays inert. + _link = new Border { Child = _text, CornerRadius = new CornerRadius(ShellTheme.RadiusSm), Background = Brushes.Transparent }; + _link.PointerEntered += (_, _) => { _hovered = true; UpdateVisualState(); }; + _link.PointerExited += (_, _) => { _hovered = false; UpdateVisualState(); }; + _link.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + }; + + _separator = new Icon { Kind = IconName.ChevronRight, Size = 14, Token = ShellToken.MutedForeground, Margin = new Thickness(6, 0), VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel { Orientation = Orientation.Horizontal, Children = { _link, _separator } }; + ShellFocus.Ring(this, _link); + UpdateVisualState(); + } + + public void SetSeparatorVisible(bool visible) => _separator.IsVisible = visible; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space) || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void UpdateVisualState() + { + _text.Text = Text ?? string.Empty; + _text.Token(TextBlock.ForegroundProperty, IsCurrent || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground); + // The current page is a label, not a link: no tab stop and no hand cursor. + Focusable = !IsCurrent; + _link.Cursor = IsCurrent ? Cursor.Default : new Cursor(StandardCursorType.Hand); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Collapsible.cs b/examples/Avalonia.Demo/Components/UI/Collapsible.cs new file mode 100644 index 0000000..09f9de7 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Collapsible.cs @@ -0,0 +1,57 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace AvaloniaDemo.Components.UI; + +// Expand/collapse. The trigger can wrap a Button or any control; content animates its height. +// +// +// +// +public class Collapsible : StackPanel +{ + public static readonly StyledProperty OpenProperty = + AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); + + static Collapsible() + { + OpenProperty.Changed.AddClassHandler((c, _) => + { + c.Refresh(animate: c.IsLoaded); + c.OpenChanged?.Invoke(c, c.Open); + }); + } + + public bool Open + { + get => GetValue(OpenProperty); + set => SetValue(OpenProperty, value); + } + + public event EventHandler? OpenChanged; + + public Collapsible() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetOpen(bool value) => Open = value; + + public void Toggle() => Open = !Open; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // The collapsible drives its content, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var content in this.FindDescendantsOfType(e => e is Collapsible)) + content.Apply(Open, animate); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/CollapsibleContent.cs b/examples/Avalonia.Demo/Components/UI/CollapsibleContent.cs new file mode 100644 index 0000000..feb3956 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/CollapsibleContent.cs @@ -0,0 +1,19 @@ +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Content shown while the enclosing Collapsible is open; expands/collapses its height. +public class CollapsibleContent : Border +{ + public CollapsibleContent() + { + IsVisible = false; + Opacity = 0; + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs b/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs new file mode 100644 index 0000000..98f1172 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/CollapsibleTrigger.cs @@ -0,0 +1,7 @@ +namespace AvaloniaDemo.Components.UI; + +// Toggles the enclosing Collapsible. Wrap a Button or any control (a row, an icon, a label). +public class CollapsibleTrigger : ShellTriggerView +{ + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); +} diff --git a/examples/Avalonia.Demo/Components/UI/DialogContent.cs b/examples/Avalonia.Demo/Components/UI/DialogContent.cs index 1eb34d4..248eed5 100644 --- a/examples/Avalonia.Demo/Components/UI/DialogContent.cs +++ b/examples/Avalonia.Demo/Components/UI/DialogContent.cs @@ -67,6 +67,14 @@ public DialogContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(_close, _close); + _close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _box = new Border { diff --git a/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs b/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs index c7759fe..2d48960 100644 --- a/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs +++ b/examples/Avalonia.Demo/Components/UI/ElementExtensions.cs @@ -1,7 +1,14 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.LogicalTree; +using Avalonia.VisualTree; namespace AvaloniaDemo.Components.UI; @@ -29,4 +36,63 @@ public static IEnumerable FindDescendantsOfType(this ILogical root, Func Expanding = new(); + + // Grows the control from its current height to its natural height and fades it in, or the + // reverse, then hides it. Collapsible and accordion content. + public static async Task AnimateExpandAsync(this Control control, bool open, int milliseconds = 200) + { + var run = new object(); + Expanding.AddOrUpdate(control, run); + control.ClipToBounds = true; + control.Transitions = null; + + double start; + double target; + if (open) + { + start = control.IsVisible ? control.Bounds.Height : 0; + control.Height = double.NaN; + control.IsVisible = true; + // Hidden content has no width yet: measure it at its parent's. + var width = control.GetVisualParent() is Layoutable parent ? parent.Bounds.Width : double.PositiveInfinity; + control.Measure(new Size(width, double.PositiveInfinity)); + target = control.DesiredSize.Height - control.Margin.Top - control.Margin.Bottom; + } + else + { + if (!control.IsVisible) return; + start = control.Bounds.Height; + target = 0; + } + + control.Height = start; + var duration = TimeSpan.FromMilliseconds(milliseconds); + Easing easing = open ? new CubicEaseOut() : new CubicEaseIn(); + control.Transitions = new Transitions + { + new DoubleTransition { Property = Layoutable.HeightProperty, Duration = duration, Easing = easing }, + new DoubleTransition { Property = Visual.OpacityProperty, Duration = duration, Easing = easing } + }; + control.Height = target; + control.Opacity = open ? 1 : 0; + await Task.Delay(duration); + + if (!Expanding.TryGetValue(control, out var latest) || latest != run) return; + control.Transitions = null; + control.Height = double.NaN; + if (!open) control.IsVisible = false; + } + + // Jumps to the open or closed state without animating. + public static void SetExpanded(this Control control, bool open) + { + Expanding.Remove(control); + control.Transitions = null; + control.Height = double.NaN; + control.IsVisible = open; + control.Opacity = open ? 1 : 0; + } } diff --git a/examples/Avalonia.Demo/Components/UI/ScrollArea.cs b/examples/Avalonia.Demo/Components/UI/ScrollArea.cs new file mode 100644 index 0000000..1f1d0dd --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/ScrollArea.cs @@ -0,0 +1,20 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; + +namespace AvaloniaDemo.Components.UI; + +// Scrollable region with Fluent's thin scrollbar, which widens on hover and hides when idle. +// For the shadcn look, wrap it in a 1px border with 6px corners: +// ... +public class ScrollArea : ScrollViewer +{ + // Keeps ScrollViewer's theme; a subclass would otherwise have none. + protected override Type StyleKeyOverride => typeof(ScrollViewer); + + public ScrollArea() + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/SheetContent.cs b/examples/Avalonia.Demo/Components/UI/SheetContent.cs index f67d0a8..7081a32 100644 --- a/examples/Avalonia.Demo/Components/UI/SheetContent.cs +++ b/examples/Avalonia.Demo/Components/UI/SheetContent.cs @@ -52,6 +52,14 @@ public SheetContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(close, close); + close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _panel = new Border { diff --git a/examples/Avalonia.Demo/Components/UI/Shell.cs b/examples/Avalonia.Demo/Components/UI/Shell.cs index 3827e28..085e5a9 100644 --- a/examples/Avalonia.Demo/Components/UI/Shell.cs +++ b/examples/Avalonia.Demo/Components/UI/Shell.cs @@ -17,6 +17,8 @@ using Avalonia.Media.Transformation; using Avalonia.Metadata; using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; namespace AvaloniaDemo.Components.UI; @@ -392,6 +394,9 @@ public sealed class ShellAnchoredPopup : IShellPopup public bool IsOpen => _open; + // Space between the anchor and the panel, on whichever side the panel opens. + public double Gap { get; set; } = 4; + // Raised when it closes for any reason: Close(), a click outside, Escape or another menu opening. public event EventHandler? Closed; @@ -400,7 +405,6 @@ public ShellAnchoredPopup(Control anchor, Popup? popup = null) Popup = popup ?? new Popup { ShouldUseOverlayLayer = true }; Popup.PlacementTarget = anchor; Popup.Placement = PlacementMode.BottomEdgeAlignedLeft; - Popup.VerticalOffset = 4; Popup.IsLightDismissEnabled = true; // The click that dismisses (even on the trigger) does nothing else, so it can't reopen. Popup.OverlayDismissEventPassThrough = false; @@ -418,7 +422,14 @@ public async void Open() ShellDismiss.Push(this, Close, anchor); panel.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative); ShellMotion.Set(panel, 0, "scale(0.95)"); + Popup.VerticalOffset = Gap; Popup.IsOpen = true; + // Avalonia flips a panel that doesn't fit below to above the anchor but keeps the offset's + // sign, which pushes it onto the anchor; mirror the offset once the flip is known. + Dispatcher.UIThread.Post(() => + { + if (_open && panel.TranslatePoint(default, anchor) is { Y: < 0 }) Popup.VerticalOffset = -Gap; + }, DispatcherPriority.Loaded); await ShellMotion.To(panel, 1, "scale(1)", 120, new CubicEaseOut()); } @@ -483,7 +494,7 @@ private void OnOpenChanged() } if (FloatingContent is null) return; Popup.Placement = Placement; - Popup.VerticalOffset = Offset; + _anchored.Gap = Offset; _anchored.Open(); } } @@ -496,13 +507,15 @@ public interface IShellOverlayContent /* Shared host for Dialog / Drawer / Sheet. Children: an optional *Trigger (rendered in place) and the *Content, which covers the window while Open, so the host can sit next to the button that - opens it. */ + opens it. While open, keyboard focus stays inside the content (Tab cycles through it); on close + it returns to the control that had it, usually the trigger. */ public abstract class ShellOverlayHost : ShellFloatingHost { public static readonly StyledProperty OpenProperty = AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); private TopLevel? _top; + private IInputElement? _returnFocus; private int _version; static ShellOverlayHost() @@ -549,11 +562,15 @@ private async void OnOpenChanged() } Fit(content); Popup.PlacementTarget = top; + _returnFocus = top.FocusManager?.GetFocusedElement(); Popup.IsOpen = true; + TrapFocus(content); if (content is IShellOverlayContent animated) await animated.AnimateAsync(true); } else { + if (_returnFocus is InputElement previous && TopLevel.GetTopLevel(previous) != null) previous.Focus(); + _returnFocus = null; if (content is IShellOverlayContent animated) await animated.AnimateAsync(false); if (version != _version) return; // reopened while closing Popup.IsOpen = false; @@ -562,6 +579,22 @@ private async void OnOpenChanged() } } + // Tab and Shift+Tab wrap inside the content, and focus starts on its first tab stop (the + // content itself when it has none), so nothing behind the backdrop can be reached. + private static void TrapFocus(Control content) + { + KeyboardNavigation.SetTabNavigation(content, KeyboardNavigationMode.Cycle); + var first = content.GetVisualDescendants().OfType().FirstOrDefault(e => + e.Focusable && e.IsEffectivelyEnabled && e.IsEffectivelyVisible && KeyboardNavigation.GetIsTabStop(e)); + if (first is null) + { + content.Focusable = true; + content.FocusAdorner = null; + first = content; + } + first.Focus(); + } + private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e) { if (FloatingContent is { } content) Fit(content); diff --git a/examples/Avalonia.Demo/Components/UI/Skeleton.cs b/examples/Avalonia.Demo/Components/UI/Skeleton.cs new file mode 100644 index 0000000..892e1fa --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Skeleton.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Styling; + +namespace AvaloniaDemo.Components.UI; + +// Loading placeholder — rounded-md bg-muted animate-pulse (opacity 1 → 0.5 → 1 every 2s). +// Sized by its parent; set Width, Height (default 20) and CornerRadius for other shapes. +public class Skeleton : Border +{ + private static readonly Animation Pulse = new() + { + Duration = TimeSpan.FromSeconds(1), + IterationCount = IterationCount.Infinite, + PlaybackDirection = PlaybackDirection.Alternate, + Easing = new SineEaseInOut(), + Children = + { + new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 1.0) } }, + new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 0.5) } } + } + }; + + private CancellationTokenSource? _pulse; + + public Skeleton() + { + Height = 20; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + this.Token(BackgroundProperty, ShellToken.Muted); + } + + // Only animates while on screen. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _pulse?.Cancel(); + _pulse = new CancellationTokenSource(); + _ = Pulse.RunAsync(this, _pulse.Token); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _pulse?.Cancel(); + _pulse = null; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Tabs.cs b/examples/Avalonia.Demo/Components/UI/Tabs.cs new file mode 100644 index 0000000..080c74f --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Tabs.cs @@ -0,0 +1,62 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace AvaloniaDemo.Components.UI; + +// Usage: +// +// +// +// +// +// ... +// ... +// +public class Tabs : StackPanel +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + static Tabs() + { + ValueProperty.Changed.AddClassHandler((t, e) => + { + t.Refresh(animate: t.IsLoaded); + t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); + }); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // (oldValue, newValue) + public event EventHandler<(string Old, string New)>? ValueChanged; + + public Tabs() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetValue(string? value) => Value = value; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // Tabs drives its triggers and panels, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var trigger in this.FindDescendantsOfType(e => e is Tabs)) + trigger.SetActive(trigger.Value == Value); + foreach (var content in this.FindDescendantsOfType(e => e is Tabs)) + content.SetActive(content.Value == Value, animate); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/TabsContent.cs b/examples/Avalonia.Demo/Components/UI/TabsContent.cs new file mode 100644 index 0000000..70ee663 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TabsContent.cs @@ -0,0 +1,43 @@ +using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; + +namespace AvaloniaDemo.Components.UI; + +// Panel shown while its Value is the active tab; fades in on switch. +public class TabsContent : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public TabsContent() + { + IsVisible = false; + } + + internal void SetActive(bool active, bool animate) + { + if (IsVisible == active) return; + Transitions = null; + IsVisible = active; + if (!active || !animate) + { + Opacity = 1; + return; + } + Opacity = 0; + Transitions = new Transitions + { + new DoubleTransition { Property = OpacityProperty, Duration = TimeSpan.FromMilliseconds(150), Easing = new CubicEaseOut() } + }; + Opacity = 1; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/TabsList.cs b/examples/Avalonia.Demo/Components/UI/TabsList.cs new file mode 100644 index 0000000..42b2cda --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TabsList.cs @@ -0,0 +1,25 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Tab strip — inline-flex h-10 rounded-lg bg-muted p-1. +public class TabsList : Border +{ + private readonly StackPanel _stack = new() { Orientation = Orientation.Horizontal }; + + [Content] + public Controls Items => _stack.Children; + + public TabsList() + { + Child = _stack; + Height = 40; + Padding = new Thickness(4); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + HorizontalAlignment = HorizontalAlignment.Left; + this.Token(BackgroundProperty, ShellToken.Muted); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs b/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs new file mode 100644 index 0000000..15d9386 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/TabsTrigger.cs @@ -0,0 +1,103 @@ +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Tab button — rounded-md px-3 text-sm font-medium; active: bg-background text-foreground +// shadow-sm; inactive: text-muted-foreground. Left and Right select the neighbouring tab. +public class TabsTrigger : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + private readonly Border _pill; + private readonly TextBlock _label; + + static TabsTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.Opacity = t.IsEnabled ? 1.0 : 0.5); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsActive { get; private set; } + + public TabsTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center }; + // The ring and the active shadow are both box shadows, so each gets its own Border. + _pill = new Border { Child = _label, Padding = new Thickness(12, 0), CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; + Child = _pill; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + SetActive(false); + } + + private void Select() => this.FindParentOfType()?.SetValue(Value); + + internal void SetActive(bool active) + { + IsActive = active; + _label.Token(TextBlock.ForegroundProperty, active ? ShellToken.Foreground : ShellToken.MutedForeground); + if (active) + { + _pill.Token(BackgroundProperty, ShellToken.Background); + _pill.BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 1, Blur = 2, Color = ShellTheme.Shadow(0.1) }); + } + else + { + _pill.ClearToken(BackgroundProperty); + _pill.Background = Brushes.Transparent; + _pill.BoxShadow = default; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + Select(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + switch (e.Key) + { + case Key.Enter or Key.Space: + Select(); + break; + case Key.Left or Key.Right when Parent is Panel strip: + var tabs = strip.Children.OfType().Where(t => t.IsEffectivelyEnabled).ToList(); + var index = tabs.IndexOf(this) + (e.Key == Key.Right ? 1 : -1); + if (index < 0 || index >= tabs.Count) return; + tabs[index].Focus(NavigationMethod.Directional); + tabs[index].Select(); + break; + default: + return; + } + e.Handled = true; + } +} diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 71466bd..ae876ae 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -3,6 +3,7 @@ xmlns:ui="using:AvaloniaDemo.Components.UI" x:Class="AvaloniaDemo.MainWindow" Title="ShellUI Native (Avalonia)" + Icon="/Assets/icon.ico" Width="1000" Height="760" Background="{DynamicResource ShellUIBackgroundBrush}"> @@ -195,6 +196,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 27ba210..0cafe7d 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -19,6 +19,17 @@ public MainWindow() CountrySelect.ItemsSource = new[] { "Argentina", "Brazil", "Japan", "Kenya", "Norway", "South Africa", "Zimbabwe" }; VolumeSlider.ValueChanged += (_, value) => VolumeText.Text = $"Volume {value:0}"; + foreach (var crumb in DemoBreadcrumb.Children.OfType()) + crumb.Clicked += (_, _) => NavChoice.Text = $"Went to {crumb.Text}."; + DemoAccordion.ItemToggled += (_, e) => NavChoice.Text = $"Section {e.Value} {(e.IsOpen ? "opened" : "closed")}."; + DemoTabs.ValueChanged += (_, e) => NavChoice.Text = $"Tab {e.Old} → {e.New}."; + for (var i = 1; i <= 30; i++) + { + var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 }; + tag.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + Tags.Children.Add(tag); + } + var clicks = 0; LoadingButton.Clicked += async (_, _) => { diff --git a/examples/MAUI.Demo/MAUI.Demo.csproj b/examples/MAUI.Demo/MAUI.Demo.csproj index 9b0b63e..27bd530 100644 --- a/examples/MAUI.Demo/MAUI.Demo.csproj +++ b/examples/MAUI.Demo/MAUI.Demo.csproj @@ -44,8 +44,12 @@ - - + + + + diff --git a/examples/MAUI.Demo/Resources/AppIcon/Android/appicon.svg b/examples/MAUI.Demo/Resources/AppIcon/Android/appicon.svg new file mode 100644 index 0000000..9c34848 --- /dev/null +++ b/examples/MAUI.Demo/Resources/AppIcon/Android/appicon.svg @@ -0,0 +1 @@ + diff --git a/examples/MAUI.Demo/Resources/AppIcon/Android/appiconfg.svg b/examples/MAUI.Demo/Resources/AppIcon/Android/appiconfg.svg new file mode 100644 index 0000000..1329a3b --- /dev/null +++ b/examples/MAUI.Demo/Resources/AppIcon/Android/appiconfg.svg @@ -0,0 +1 @@ + diff --git a/examples/MAUI.Demo/Resources/AppIcon/appicon.svg b/examples/MAUI.Demo/Resources/AppIcon/appicon.svg index 9c34848..747d213 100644 --- a/examples/MAUI.Demo/Resources/AppIcon/appicon.svg +++ b/examples/MAUI.Demo/Resources/AppIcon/appicon.svg @@ -1 +1 @@ - + diff --git a/examples/MAUI.Demo/Resources/AppIcon/appiconfg.svg b/examples/MAUI.Demo/Resources/AppIcon/appiconfg.svg deleted file mode 100644 index efbdca4..0000000 --- a/examples/MAUI.Demo/Resources/AppIcon/appiconfg.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py index 2d2b88d..eebda55 100644 --- a/scripts/sync-templates.py +++ b/scripts/sync-templates.py @@ -98,7 +98,7 @@ r"\bShellTriggerView\b|\bShellOverlayHost\b|\bShellPopoverHost\b|\bShellPortal\b|" r"\bShellPopup\w+\b|\bIShellFocusable\b|\bIShellPopup\b|\bIShellOverlayContent\b") ICON_API = re.compile(r"\bnew Icon\b|\bIconName\b") -EXT_API = re.compile(r"\bFindParentOfType\b|\bFindDescendantsOfType\b|\bAnimateExpandAsync\b") +EXT_API = re.compile(r"\bFindParentOfType\b|\bFindDescendantsOfType\b|\bAnimateExpandAsync\b|\bSetExpanded\b") # A block ends at the closing quote that is followed by the next entry or the end of the dictionary. MAUI_BLOCK = re.compile(r'(\[NativePlatform\.MAUI\] = @")(.*?)(\n"(?=,\n|\n \};))', re.S) diff --git a/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs index 88f9ac6..57c5626 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionContentTemplate.cs @@ -44,6 +44,28 @@ internal void Apply(bool open, bool animate) HeightRequest = -1; } } +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Section body — pb-4 text-sm; expands/collapses its height with the item. +public class AccordionContent : Border +{ + public AccordionContent() + { + IsVisible = false; + Opacity = 0; + Padding = new Thickness(0, 0, 0, 16); + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs index 5d91a6a..78adfd5 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionItemTemplate.cs @@ -64,6 +64,57 @@ internal void ApplyOpen(bool open, bool animate) if (changed) OpenChanged?.Invoke(this, open); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// One section: trigger + content, with a bottom divider (border-b, none on the last item). +public class AccordionItem : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly StackPanel _stack = new(); + private readonly Border _divider = new() { Height = 1 }; + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + [Content] + public Controls Items => _stack.Children; + + public bool IsOpen { get; private set; } + + public event EventHandler? OpenChanged; + + public AccordionItem() + { + _divider.Token(BackgroundProperty, ShellToken.Border); + Child = new StackPanel { Children = { _stack, _divider } }; + } + + public void Toggle() => this.FindParentOfType()?.Toggle(Value ?? string.Empty); + + internal void SetDividerVisible(bool visible) => _divider.IsVisible = visible; + + internal void ApplyOpen(bool open, bool animate) + { + var changed = IsOpen != open; + IsOpen = open; + foreach (var trigger in this.FindDescendantsOfType(e => e is AccordionItem)) + trigger.SetOpen(open, animate); + foreach (var content in this.FindDescendantsOfType(e => e is AccordionItem)) + content.Apply(open, animate); + if (changed) OpenChanged?.Invoke(this, open); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs index 751b77e..a58cfd1 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionTemplate.cs @@ -110,6 +110,112 @@ private void Refresh(bool animate) } } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +public enum AccordionType { Single, Multiple } + +// Stacked expandable sections with dividers and rotating chevrons. +// +// +// +// +// +// +public class Accordion : StackPanel +{ + public static readonly StyledProperty TypeProperty = + AvaloniaProperty.Register(nameof(Type)); + + // Initially open item (Single) — or comma-separated items (Multiple). + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + private readonly HashSet _openValues = new(); + + static Accordion() + { + ValueProperty.Changed.AddClassHandler((a, _) => a.OnValueChanged()); + } + + public AccordionType Type + { + get => GetValue(TypeProperty); + set => SetValue(TypeProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // Fired when any item's open state changes: (value, isOpen). + public event EventHandler<(string Value, bool IsOpen)>? ItemToggled; + + public Accordion() + { + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public bool IsOpen(string value) => _openValues.Contains(value); + + public void Toggle(string value) + { + var wasOpen = _openValues.Contains(value); + var changed = new List<(string, bool)>(); + + if (Type == AccordionType.Single) + { + foreach (var other in _openValues.Where(v => v != value).ToList()) + { + _openValues.Remove(other); + changed.Add((other, false)); + } + } + + if (wasOpen) _openValues.Remove(value); + else _openValues.Add(value); + changed.Add((value, !wasOpen)); + + Refresh(animate: true); + foreach (var change in changed) ItemToggled?.Invoke(this, change); + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + private void OnValueChanged() + { + _openValues.Clear(); + foreach (var v in (Value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + _openValues.Add(v); + if (Type == AccordionType.Single) break; + } + Refresh(animate: IsLoaded); + } + + // The accordion drives its items, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + var items = this.FindDescendantsOfType(e => e is Accordion).ToList(); + for (var i = 0; i < items.Count; i++) + { + items[i].ApplyOpen(_openValues.Contains(items[i].Value ?? string.Empty), animate); + items[i].SetDividerVisible(i < items.Count - 1); + } + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs index 6ee935f..b26b640 100644 --- a/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/AccordionTriggerTemplate.cs @@ -91,6 +91,98 @@ private void UpdateBody() _row.Add(view, 0, 0); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Media.Transformation; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// Section header — flex justify-between py-4 font-medium hover:underline, with a chevron +// that rotates 180° when open. Use Text for a plain title, or put any control inside. +public class AccordionTrigger : ShellTriggerView +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + private readonly Grid _row; + private readonly TextBlock _label; + private readonly Icon _chevron; + + static AccordionTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + BodyProperty.Changed.AddClassHandler((t, e) => + { + t._row.Children.Remove((Control?)e.OldValue ?? t._label); + t._row.Children.Insert(0, t.Body ?? t._label); + }); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public AccordionTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _chevron = new Icon { Kind = IconName.ChevronDown, Size = 16, Token = ShellToken.MutedForeground, VerticalAlignment = VerticalAlignment.Center }; + Grid.SetColumn(_chevron, 1); + + _row = new Grid + { + ColumnDefinitions = new ColumnDefinitions(""*,Auto""), + ColumnSpacing = 16, + Margin = new Thickness(0, 16), + Children = { _label, _chevron } + }; + + Child = _row; + HorizontalAlignment = HorizontalAlignment.Stretch; + } + + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + _label.TextDecorations = TextDecorations.Underline; + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + _label.TextDecorations = null; + } + + internal void SetOpen(bool open, bool animate) + { + _chevron.Transitions = animate + ? new Transitions { new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() } } + : null; + _chevron.RenderTransform = TransformOperations.Parse(open ? ""rotate(180deg)"" : ""rotate(0deg)""); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs b/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs index 7b18354..6504976 100644 --- a/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/BreadcrumbItemTemplate.cs @@ -83,6 +83,90 @@ private void UpdateVisualState() IsCurrent || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Breadcrumb link — text-sm text-muted-foreground hover:text-foreground; the current page is +// text-foreground and not clickable. A chevron separator follows every item but the last. +public class BreadcrumbItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IsCurrentProperty = + AvaloniaProperty.Register(nameof(IsCurrent)); + + private readonly Border _link; + private readonly TextBlock _text; + private readonly Icon _separator; + private bool _hovered; + + static BreadcrumbItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + IsCurrentProperty.Changed.AddClassHandler((i, _) => i.UpdateVisualState()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsCurrent + { + get => GetValue(IsCurrentProperty); + set => SetValue(IsCurrentProperty, value); + } + + public event EventHandler? Clicked; + + public BreadcrumbItem() + { + _text = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + // Takes the hover, click and focus ring, so the separator stays inert. + _link = new Border { Child = _text, CornerRadius = new CornerRadius(ShellTheme.RadiusSm), Background = Brushes.Transparent }; + _link.PointerEntered += (_, _) => { _hovered = true; UpdateVisualState(); }; + _link.PointerExited += (_, _) => { _hovered = false; UpdateVisualState(); }; + _link.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + }; + + _separator = new Icon { Kind = IconName.ChevronRight, Size = 14, Token = ShellToken.MutedForeground, Margin = new Thickness(6, 0), VerticalAlignment = VerticalAlignment.Center }; + Child = new StackPanel { Orientation = Orientation.Horizontal, Children = { _link, _separator } }; + ShellFocus.Ring(this, _link); + UpdateVisualState(); + } + + public void SetSeparatorVisible(bool visible) => _separator.IsVisible = visible; + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space) || IsCurrent) return; + Clicked?.Invoke(this, EventArgs.Empty); + e.Handled = true; + } + + private void UpdateVisualState() + { + _text.Text = Text ?? string.Empty; + _text.Token(TextBlock.ForegroundProperty, IsCurrent || _hovered ? ShellToken.Foreground : ShellToken.MutedForeground); + // The current page is a label, not a link: no tab stop and no hand cursor. + Focusable = !IsCurrent; + _link.Cursor = IsCurrent ? Cursor.Default : new Cursor(StandardCursorType.Hand); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs b/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs index bfaba8a..4b69b8f 100644 --- a/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/BreadcrumbTemplate.cs @@ -49,6 +49,33 @@ private void RefreshItemSeparators() items[i].SetSeparatorVisible(i < items.Count - 1); } } +", + [NativePlatform.Avalonia] = @"using System.Linq; +using Avalonia.Controls; +using Avalonia.Layout; + +namespace YourProjectNamespace.Components.UI; + +// Navigation trail — items separated by chevrons; the last item hides its separator. +// +// +// +// +public class Breadcrumb : StackPanel +{ + public Breadcrumb() + { + Orientation = Orientation.Horizontal; + Children.CollectionChanged += (_, _) => RefreshItemSeparators(); + } + + private void RefreshItemSeparators() + { + var items = Children.OfType().ToList(); + for (var i = 0; i < items.Count; i++) + items[i].SetSeparatorVisible(i < items.Count - 1); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs index 80d59ff..815f988 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleContentTemplate.cs @@ -46,6 +46,26 @@ internal void Apply(bool open, bool animate) HeightRequest = -1; } } +", + [NativePlatform.Avalonia] = @"using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Content shown while the enclosing Collapsible is open; expands/collapses its height. +public class CollapsibleContent : Border +{ + public CollapsibleContent() + { + IsVisible = false; + Opacity = 0; + } + + internal void Apply(bool open, bool animate) + { + if (animate) _ = this.AnimateExpandAsync(open); + else this.SetExpanded(open); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs index 97b90ec..d62e109 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTemplate.cs @@ -64,6 +64,64 @@ private void Refresh(bool animate) content.Apply(Open, animate); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace YourProjectNamespace.Components.UI; + +// Expand/collapse. The trigger can wrap a Button or any control; content animates its height. +// +// +// +// +public class Collapsible : StackPanel +{ + public static readonly StyledProperty OpenProperty = + AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); + + static Collapsible() + { + OpenProperty.Changed.AddClassHandler((c, _) => + { + c.Refresh(animate: c.IsLoaded); + c.OpenChanged?.Invoke(c, c.Open); + }); + } + + public bool Open + { + get => GetValue(OpenProperty); + set => SetValue(OpenProperty, value); + } + + public event EventHandler? OpenChanged; + + public Collapsible() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetOpen(bool value) => Open = value; + + public void Toggle() => Open = !Open; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // The collapsible drives its content, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var content in this.FindDescendantsOfType(e => e is Collapsible)) + content.Apply(Open, animate); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs index 238d43c..1015765 100644 --- a/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/CollapsibleTriggerTemplate.cs @@ -26,6 +26,14 @@ public partial class CollapsibleTrigger : ShellTriggerView { protected override void OnActivated() => this.FindParentOfType()?.Toggle(); } +", + [NativePlatform.Avalonia] = @"namespace YourProjectNamespace.Components.UI; + +// Toggles the enclosing Collapsible. Wrap a Button or any control (a row, an icon, a label). +public class CollapsibleTrigger : ShellTriggerView +{ + protected override void OnActivated() => this.FindParentOfType()?.Toggle(); +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs index 8407f6e..bff6eea 100644 --- a/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/DialogContentTemplate.cs @@ -188,6 +188,14 @@ public DialogContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(_close, _close); + _close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _box = new Border { diff --git a/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs b/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs index c656e8b..617d01e 100644 --- a/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ElementExtensionsTemplate.cs @@ -101,8 +101,15 @@ public static Task AnimateExpandAsync(this View view, bool open, uint length = 2 ", [NativePlatform.Avalonia] = @"using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Layout; using Avalonia.LogicalTree; +using Avalonia.VisualTree; namespace YourProjectNamespace.Components.UI; @@ -130,6 +137,65 @@ public static IEnumerable FindDescendantsOfType(this ILogical root, Func Expanding = new(); + + // Grows the control from its current height to its natural height and fades it in, or the + // reverse, then hides it. Collapsible and accordion content. + public static async Task AnimateExpandAsync(this Control control, bool open, int milliseconds = 200) + { + var run = new object(); + Expanding.AddOrUpdate(control, run); + control.ClipToBounds = true; + control.Transitions = null; + + double start; + double target; + if (open) + { + start = control.IsVisible ? control.Bounds.Height : 0; + control.Height = double.NaN; + control.IsVisible = true; + // Hidden content has no width yet: measure it at its parent's. + var width = control.GetVisualParent() is Layoutable parent ? parent.Bounds.Width : double.PositiveInfinity; + control.Measure(new Size(width, double.PositiveInfinity)); + target = control.DesiredSize.Height - control.Margin.Top - control.Margin.Bottom; + } + else + { + if (!control.IsVisible) return; + start = control.Bounds.Height; + target = 0; + } + + control.Height = start; + var duration = TimeSpan.FromMilliseconds(milliseconds); + Easing easing = open ? new CubicEaseOut() : new CubicEaseIn(); + control.Transitions = new Transitions + { + new DoubleTransition { Property = Layoutable.HeightProperty, Duration = duration, Easing = easing }, + new DoubleTransition { Property = Visual.OpacityProperty, Duration = duration, Easing = easing } + }; + control.Height = target; + control.Opacity = open ? 1 : 0; + await Task.Delay(duration); + + if (!Expanding.TryGetValue(control, out var latest) || latest != run) return; + control.Transitions = null; + control.Height = double.NaN; + if (!open) control.IsVisible = false; + } + + // Jumps to the open or closed state without animating. + public static void SetExpanded(this Control control, bool open) + { + Expanding.Remove(control); + control.Transitions = null; + control.Height = double.NaN; + control.IsVisible = open; + control.Opacity = open ? 1 : 0; + } } " }; diff --git a/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs b/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs index 5b7a0b4..64fac79 100644 --- a/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ScrollAreaTemplate.cs @@ -33,6 +33,27 @@ public ScrollArea() VerticalScrollBarVisibility = ScrollBarVisibility.Default; } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; + +namespace YourProjectNamespace.Components.UI; + +// Scrollable region with Fluent's thin scrollbar, which widens on hover and hides when idle. +// For the shadcn look, wrap it in a 1px border with 6px corners: +// ... +public class ScrollArea : ScrollViewer +{ + // Keeps ScrollViewer's theme; a subclass would otherwise have none. + protected override Type StyleKeyOverride => typeof(ScrollViewer); + + public ScrollArea() + { + VerticalScrollBarVisibility = ScrollBarVisibility.Auto; + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs index b5d325a..bc1757a 100644 --- a/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SheetContentTemplate.cs @@ -200,6 +200,14 @@ public SheetContent() this.FindParentOfType()?.SetOpen(false); e.Handled = true; }; + // A tab stop inside the focus trap, as in shadcn; Enter or Space closes. + ShellFocus.Ring(close, close); + close.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + this.FindParentOfType()?.SetOpen(false); + e.Handled = true; + }; _panel = new Border { diff --git a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs index 0f42ce5..e419b50 100644 --- a/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ShellTemplate.cs @@ -1079,6 +1079,8 @@ public static void ActivateAncestor(Element from) using Avalonia.Media.Transformation; using Avalonia.Metadata; using Avalonia.Styling; +using Avalonia.Threading; +using Avalonia.VisualTree; namespace YourProjectNamespace.Components.UI; @@ -1454,6 +1456,9 @@ public sealed class ShellAnchoredPopup : IShellPopup public bool IsOpen => _open; + // Space between the anchor and the panel, on whichever side the panel opens. + public double Gap { get; set; } = 4; + // Raised when it closes for any reason: Close(), a click outside, Escape or another menu opening. public event EventHandler? Closed; @@ -1462,7 +1467,6 @@ public ShellAnchoredPopup(Control anchor, Popup? popup = null) Popup = popup ?? new Popup { ShouldUseOverlayLayer = true }; Popup.PlacementTarget = anchor; Popup.Placement = PlacementMode.BottomEdgeAlignedLeft; - Popup.VerticalOffset = 4; Popup.IsLightDismissEnabled = true; // The click that dismisses (even on the trigger) does nothing else, so it can't reopen. Popup.OverlayDismissEventPassThrough = false; @@ -1480,7 +1484,14 @@ public async void Open() ShellDismiss.Push(this, Close, anchor); panel.RenderTransformOrigin = new RelativePoint(0.5, 0, RelativeUnit.Relative); ShellMotion.Set(panel, 0, ""scale(0.95)""); + Popup.VerticalOffset = Gap; Popup.IsOpen = true; + // Avalonia flips a panel that doesn't fit below to above the anchor but keeps the offset's + // sign, which pushes it onto the anchor; mirror the offset once the flip is known. + Dispatcher.UIThread.Post(() => + { + if (_open && panel.TranslatePoint(default, anchor) is { Y: < 0 }) Popup.VerticalOffset = -Gap; + }, DispatcherPriority.Loaded); await ShellMotion.To(panel, 1, ""scale(1)"", 120, new CubicEaseOut()); } @@ -1545,7 +1556,7 @@ private void OnOpenChanged() } if (FloatingContent is null) return; Popup.Placement = Placement; - Popup.VerticalOffset = Offset; + _anchored.Gap = Offset; _anchored.Open(); } } @@ -1558,13 +1569,15 @@ public interface IShellOverlayContent /* Shared host for Dialog / Drawer / Sheet. Children: an optional *Trigger (rendered in place) and the *Content, which covers the window while Open, so the host can sit next to the button that - opens it. */ + opens it. While open, keyboard focus stays inside the content (Tab cycles through it); on close + it returns to the control that had it, usually the trigger. */ public abstract class ShellOverlayHost : ShellFloatingHost { public static readonly StyledProperty OpenProperty = AvaloniaProperty.Register(nameof(Open), defaultBindingMode: BindingMode.TwoWay); private TopLevel? _top; + private IInputElement? _returnFocus; private int _version; static ShellOverlayHost() @@ -1611,11 +1624,15 @@ private async void OnOpenChanged() } Fit(content); Popup.PlacementTarget = top; + _returnFocus = top.FocusManager?.GetFocusedElement(); Popup.IsOpen = true; + TrapFocus(content); if (content is IShellOverlayContent animated) await animated.AnimateAsync(true); } else { + if (_returnFocus is InputElement previous && TopLevel.GetTopLevel(previous) != null) previous.Focus(); + _returnFocus = null; if (content is IShellOverlayContent animated) await animated.AnimateAsync(false); if (version != _version) return; // reopened while closing Popup.IsOpen = false; @@ -1624,6 +1641,22 @@ private async void OnOpenChanged() } } + // Tab and Shift+Tab wrap inside the content, and focus starts on its first tab stop (the + // content itself when it has none), so nothing behind the backdrop can be reached. + private static void TrapFocus(Control content) + { + KeyboardNavigation.SetTabNavigation(content, KeyboardNavigationMode.Cycle); + var first = content.GetVisualDescendants().OfType().FirstOrDefault(e => + e.Focusable && e.IsEffectivelyEnabled && e.IsEffectivelyVisible && KeyboardNavigation.GetIsTabStop(e)); + if (first is null) + { + content.Focusable = true; + content.FocusAdorner = null; + first = content; + } + first.Focus(); + } + private void OnWindowSizeChanged(object? sender, SizeChangedEventArgs e) { if (FloatingContent is { } content) Fit(content); diff --git a/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs b/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs index f0db19f..e7dbb7e 100644 --- a/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/SkeletonTemplate.cs @@ -60,6 +60,59 @@ private void StartPulse() .Commit(this, ""pulse"", 16, 2000, Easing.Linear, repeat: () => true); } } +", + [NativePlatform.Avalonia] = @"using System; +using System.Threading; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; +using Avalonia.Styling; + +namespace YourProjectNamespace.Components.UI; + +// Loading placeholder — rounded-md bg-muted animate-pulse (opacity 1 → 0.5 → 1 every 2s). +// Sized by its parent; set Width, Height (default 20) and CornerRadius for other shapes. +public class Skeleton : Border +{ + private static readonly Animation Pulse = new() + { + Duration = TimeSpan.FromSeconds(1), + IterationCount = IterationCount.Infinite, + PlaybackDirection = PlaybackDirection.Alternate, + Easing = new SineEaseInOut(), + Children = + { + new KeyFrame { Cue = new Cue(0), Setters = { new Setter(OpacityProperty, 1.0) } }, + new KeyFrame { Cue = new Cue(1), Setters = { new Setter(OpacityProperty, 0.5) } } + } + }; + + private CancellationTokenSource? _pulse; + + public Skeleton() + { + Height = 20; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + this.Token(BackgroundProperty, ShellToken.Muted); + } + + // Only animates while on screen. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _pulse?.Cancel(); + _pulse = new CancellationTokenSource(); + _ = Pulse.RunAsync(this, _pulse.Token); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _pulse?.Cancel(); + _pulse = null; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs index 4e7db09..d64ff74 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsContentTemplate.cs @@ -52,6 +52,50 @@ internal void SetActive(bool active, bool animate) } } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Animation.Easings; +using Avalonia.Controls; + +namespace YourProjectNamespace.Components.UI; + +// Panel shown while its Value is the active tab; fades in on switch. +public class TabsContent : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public TabsContent() + { + IsVisible = false; + } + + internal void SetActive(bool active, bool animate) + { + if (IsVisible == active) return; + Transitions = null; + IsVisible = active; + if (!active || !animate) + { + Opacity = 1; + return; + } + Opacity = 0; + Transitions = new Transitions + { + new DoubleTransition { Property = OpacityProperty, Duration = TimeSpan.FromMilliseconds(150), Easing = new CubicEaseOut() } + }; + Opacity = 1; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs index 379f2ab..35fca0a 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsListTemplate.cs @@ -47,6 +47,32 @@ public TabsList() HorizontalOptions = LayoutOptions.Start; } } +", + [NativePlatform.Avalonia] = @"using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Metadata; + +namespace YourProjectNamespace.Components.UI; + +// Tab strip — inline-flex h-10 rounded-lg bg-muted p-1. +public class TabsList : Border +{ + private readonly StackPanel _stack = new() { Orientation = Orientation.Horizontal }; + + [Content] + public Controls Items => _stack.Children; + + public TabsList() + { + Child = _stack; + Height = 40; + Padding = new Thickness(4); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + HorizontalAlignment = HorizontalAlignment.Left; + this.Token(BackgroundProperty, ShellToken.Muted); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs index f27b72d..6c2f9a0 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsTemplate.cs @@ -68,6 +68,69 @@ private void Refresh(bool animate) content.SetActive(content.Value == Value, animate); } } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data; + +namespace YourProjectNamespace.Components.UI; + +// Usage: +// +// +// +// +// +// ... +// ... +// +public class Tabs : StackPanel +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value), defaultBindingMode: BindingMode.TwoWay); + + static Tabs() + { + ValueProperty.Changed.AddClassHandler((t, e) => + { + t.Refresh(animate: t.IsLoaded); + t.ValueChanged?.Invoke(t, ((string?)e.OldValue ?? string.Empty, (string?)e.NewValue ?? string.Empty)); + }); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + // (oldValue, newValue) + public event EventHandler<(string Old, string New)>? ValueChanged; + + public Tabs() + { + Spacing = 8; + Children.CollectionChanged += (_, _) => Refresh(animate: false); + } + + public void SetValue(string? value) => Value = value; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + Refresh(animate: false); + } + + // Tabs drives its triggers and panels, so state never depends on when a child found its parent. + private void Refresh(bool animate) + { + foreach (var trigger in this.FindDescendantsOfType(e => e is Tabs)) + trigger.SetActive(trigger.Value == Value); + foreach (var content in this.FindDescendantsOfType(e => e is Tabs)) + content.SetActive(content.Value == Value, animate); + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs index a7a7c54..132b427 100644 --- a/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/TabsTriggerTemplate.cs @@ -98,6 +98,110 @@ internal void SetActive(bool active) } } } +", + [NativePlatform.Avalonia] = @"using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Tab button — rounded-md px-3 text-sm font-medium; active: bg-background text-foreground +// shadow-sm; inactive: text-muted-foreground. Left and Right select the neighbouring tab. +public class TabsTrigger : Border +{ + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + private readonly Border _pill; + private readonly TextBlock _label; + + static TabsTrigger() + { + TextProperty.Changed.AddClassHandler((t, _) => t._label.Text = t.Text ?? string.Empty); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.Opacity = t.IsEnabled ? 1.0 : 0.5); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public bool IsActive { get; private set; } + + public TabsTrigger() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, HorizontalAlignment = HorizontalAlignment.Center }; + // The ring and the active shadow are both box shadows, so each gets its own Border. + _pill = new Border { Child = _label, Padding = new Thickness(12, 0), CornerRadius = new CornerRadius(ShellTheme.RadiusMd) }; + Child = _pill; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + SetActive(false); + } + + private void Select() => this.FindParentOfType()?.SetValue(Value); + + internal void SetActive(bool active) + { + IsActive = active; + _label.Token(TextBlock.ForegroundProperty, active ? ShellToken.Foreground : ShellToken.MutedForeground); + if (active) + { + _pill.Token(BackgroundProperty, ShellToken.Background); + _pill.BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 1, Blur = 2, Color = ShellTheme.Shadow(0.1) }); + } + else + { + _pill.ClearToken(BackgroundProperty); + _pill.Background = Brushes.Transparent; + _pill.BoxShadow = default; + } + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + Select(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + switch (e.Key) + { + case Key.Enter or Key.Space: + Select(); + break; + case Key.Left or Key.Right when Parent is Panel strip: + var tabs = strip.Children.OfType().Where(t => t.IsEffectivelyEnabled).ToList(); + var index = tabs.IndexOf(this) + (e.Key == Key.Right ? 1 : -1); + if (index < 0 || index >= tabs.Count) return; + tabs[index].Focus(NavigationMethod.Directional); + tabs[index].Select(); + break; + default: + return; + } + e.Handled = true; + } +} " }; } diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 32938c5..1cec4d4 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, P1 and P2. Grows as components are ported. + // Ported so far: the Phase 2 foundation, P0, P1, P2 and P3. Grows as components are ported. [Theory] [InlineData("shell")] [InlineData("icon")] @@ -69,6 +69,21 @@ public static IEnumerable AvaloniaComponents() [InlineData("calendar")] [InlineData("date-picker")] [InlineData("time-picker")] + [InlineData("collapsible")] + [InlineData("collapsible-trigger")] + [InlineData("collapsible-content")] + [InlineData("accordion")] + [InlineData("accordion-item")] + [InlineData("accordion-trigger")] + [InlineData("accordion-content")] + [InlineData("tabs")] + [InlineData("tabs-list")] + [InlineData("tabs-trigger")] + [InlineData("tabs-content")] + [InlineData("breadcrumb")] + [InlineData("breadcrumb-item")] + [InlineData("skeleton")] + [InlineData("scroll-area")] public void Ported_components_have_Avalonia_content(string name) { Assert.False(string.IsNullOrWhiteSpace(ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia)));