diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 9d53b47..6da1126 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -71,9 +71,10 @@ Ported so far: `shell`, `icon`, `theme-toggle`, P0 (`button`, `input`, `label`, `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`) and P4 (`spinner`, `tooltip`, -`toast`, `alert-dialog`, `hover-card` and its parts). Not yet published to NuGet. In an -Avalonia project, `shellui-native list` shows only these. +`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. 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 @@ -107,6 +108,12 @@ Same names, properties and variants as MAUI, except: | Toaster | Declared on a page | Declared in a window; `Toast.Show` without one uses the active window | | HoverCard on touch | Tap toggles | Touch, pen and Enter / Space toggle; a mouse click acts on the wrapped control only | | AlertDialog extra content | One `Body` view | Any XAML children, stacked between the description and the buttons | +| Avatar `Source` | `ImageSource` (`Source="profile.png"`) | `IImage`, an Avalonia resource path (`Source="/Assets/profile.png"`) | +| TableRow clicks | `Tapped` event on the row | Avalonia's own `Tapped` event (`TappedEventArgs`) | +| ContextMenu | Right-click, or long-press on touch | Also the Menu key / Shift+F10 while focus is inside the trigger; a right-click while it is open moves it; items are tab stops; children are collected in `Items` | +| 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) | --- diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index c557d6d..4d3a322 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -35,8 +35,9 @@ 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–P3 merged | 92 registry entries (89 CLI targets, 58 families); Avalonia templates for the foundation and P0–P3 | -| `feat/avalonia-p4-feedback` | `main` | **Active** | Phase 2 P4: Avalonia feedback and overlays | +| `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 | --- @@ -434,7 +435,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-p4-feedback` now) +## Phase 2 — Avalonia (active; one branch per tier, `feat/avalonia-p6-advanced` now) Cross-desktop (Windows + macOS + Linux) from one XAML codebase. @@ -477,6 +478,18 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase. `ShellFloatingHost.ClaimsChild` lets a host place XAML children itself. `ShellLabel` now wraps like MAUI's Label. Each installs alone into a fresh `dotnet new avalonia.app` and builds with 0 warnings; checked in the demo in light and dark +- [x] P5: avatar, toggle, callout, table (one template: Table, TableHeader, TableRow, TableHead, + TableCell), empty-state, pagination. Each installs alone into a fresh `dotnet new avalonia.app` + and builds with 0 warnings; checked in the demo in light and dark. Both demos now use ShellUI + content: Shewart Shepherd / @shewart, shellui-dev repositories, and the Shell family marks as + avatar images +- [x] P6: context-menu (one template: ContextMenu, ContextMenuTrigger, ContextMenuContent, + ContextMenuItem, ContextMenuSeparator), carousel, stepper (Stepper + StepperStep). The context + menu opens on Avalonia's `ContextRequested` (right-click, Menu key / Shift+F10) and on a touch + hold, at the pointer through `Popup.PlacementRect`; the carousel animates on the window's + 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 - [ ] 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 ba68dfe..5df1f32 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–P4 components; not on NuGet yet | .NET 10.0 (Avalonia 12) | +| 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) | | 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/Brand/shelldocs.png b/examples/Avalonia.Demo/Assets/Brand/shelldocs.png new file mode 100644 index 0000000..d28aafa Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shelldocs.png differ diff --git a/examples/Avalonia.Demo/Assets/Brand/shellicons.png b/examples/Avalonia.Demo/Assets/Brand/shellicons.png new file mode 100644 index 0000000..efeb9fb Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shellicons.png differ diff --git a/examples/Avalonia.Demo/Assets/Brand/shellui.png b/examples/Avalonia.Demo/Assets/Brand/shellui.png new file mode 100644 index 0000000..48b09d4 Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shellui.png differ diff --git a/examples/Avalonia.Demo/Assets/Brand/shellui_native.png b/examples/Avalonia.Demo/Assets/Brand/shellui_native.png new file mode 100644 index 0000000..b5277e4 Binary files /dev/null and b/examples/Avalonia.Demo/Assets/Brand/shellui_native.png differ diff --git a/examples/Avalonia.Demo/Components/UI/Avatar.cs b/examples/Avalonia.Demo/Components/UI/Avatar.cs new file mode 100644 index 0000000..9b457b7 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Avatar.cs @@ -0,0 +1,105 @@ +using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Avatar — rounded-full image over a bg-muted fallback (initials, or a user icon when no +// Fallback is set). The fallback shows wherever the image doesn't cover it. +// Usage: +public class Avatar : Border +{ + public static readonly StyledProperty SourceProperty = + AvaloniaProperty.Register(nameof(Source)); + + public static readonly StyledProperty FallbackProperty = + AvaloniaProperty.Register(nameof(Fallback)); + + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size), AvatarSize.Default); + + private readonly TextBlock _initials; + private readonly Icon _userIcon; + private readonly Image _image; + + static Avatar() + { + SourceProperty.Changed.AddClassHandler((a, _) => + { + a._image.Source = a.Source; + a._image.IsVisible = a.Source != null; + }); + FallbackProperty.Changed.AddClassHandler((a, _) => a.UpdateFallback()); + SizeProperty.Changed.AddClassHandler((a, _) => a.UpdateSize()); + } + + public IImage? Source + { + get => GetValue(SourceProperty); + set => SetValue(SourceProperty, value); + } + + public string? Fallback + { + get => GetValue(FallbackProperty); + set => SetValue(FallbackProperty, value); + } + + public AvatarSize Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + public Avatar() + { + _initials = new TextBlock + { + FontWeight = FontWeight.SemiBold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + _initials.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _userIcon = new Icon { Kind = IconName.User, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + _image = new Image { Stretch = Stretch.UniformToFill, IsVisible = false }; + + Child = new Panel { Children = { _initials, _userIcon, _image } }; + // Clips the image to the circle. + ClipToBounds = true; + HorizontalAlignment = HorizontalAlignment.Left; + VerticalAlignment = VerticalAlignment.Center; + this.Token(BackgroundProperty, ShellToken.Muted); + UpdateSize(); + UpdateFallback(); + } + + private void UpdateSize() + { + var size = Size switch + { + AvatarSize.Sm => 32, + AvatarSize.Lg => 48, + AvatarSize.Xl => 64, + _ => 40 + }; + Width = size; + Height = size; + CornerRadius = new CornerRadius(size / 2.0); + _initials.FontSize = Math.Round(size * 0.36); + _userIcon.Size = size / 2.0; + } + + private void UpdateFallback() + { + var hasText = !string.IsNullOrWhiteSpace(Fallback); + _initials.Text = Fallback ?? string.Empty; + _initials.IsVisible = hasText; + _userIcon.IsVisible = !hasText; + AutomationProperties.SetName(this, hasText ? Fallback : "Avatar"); + } +} + +public enum AvatarSize { Sm, Default, Lg, Xl } diff --git a/examples/Avalonia.Demo/Components/UI/Callout.cs b/examples/Avalonia.Demo/Components/UI/Callout.cs new file mode 100644 index 0000000..a2d7b98 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Callout.cs @@ -0,0 +1,137 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Highlighted note — rounded-lg border px-4 py-3 on a faint tint of the variant color, with an +// icon, an optional title and text and/or any content. +// +// +public class Callout : Border +{ + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant), CalloutVariant.Info); + + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // None uses the variant's own icon. + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), IconName.None); + + public static readonly StyledProperty BodyProperty = + AvaloniaProperty.Register(nameof(Body)); + + private readonly Border _tint; + private readonly Icon _icon; + private readonly TextBlock _title; + private readonly TextBlock _text; + private readonly ContentControl _body = new(); + + static Callout() + { + VariantProperty.Changed.AddClassHandler((c, _) => c.Update()); + TitleProperty.Changed.AddClassHandler((c, _) => c.Update()); + TextProperty.Changed.AddClassHandler((c, _) => c.Update()); + IconProperty.Changed.AddClassHandler((c, _) => c.Update()); + BodyProperty.Changed.AddClassHandler((c, _) => c.Update()); + } + + public CalloutVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + [Content] + public Control? Body + { + get => GetValue(BodyProperty); + set => SetValue(BodyProperty, value); + } + + public Callout() + { + _tint = new Border { CornerRadius = new CornerRadius(ShellTheme.RadiusLg - 1) }; + _icon = new Icon { Size = 16, VerticalAlignment = VerticalAlignment.Top, Margin = new Thickness(0, 2, 0, 0) }; + _title = new TextBlock { FontSize = 14, FontWeight = FontWeight.SemiBold, TextWrapping = TextWrapping.Wrap }; + _text = new TextBlock { FontSize = 14, LineHeight = 20, TextWrapping = TextWrapping.Wrap }; + _text.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + + var text = new StackPanel { Spacing = 6, Children = { _title, _text, _body } }; + Grid.SetColumn(text, 1); + var row = new Grid + { + Margin = new Thickness(16, 12), + ColumnSpacing = 12, + ColumnDefinitions = new ColumnDefinitions("Auto,*"), + Children = { _icon, text } + }; + + Child = new Panel { Children = { _tint, row } }; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + this.Token(BorderBrushProperty, ShellToken.Border); + Update(); + } + + private void Update() + { + var (icon, accent) = Variant switch + { + CalloutVariant.Warning => (IconName.TriangleAlert, ShellToken.Warning), + CalloutVariant.Danger => (IconName.CircleAlert, ShellToken.Destructive), + CalloutVariant.Tip => (IconName.Lightbulb, ShellToken.Success), + CalloutVariant.Default => (IconName.Info, ShellToken.MutedForeground), + _ => (IconName.Info, ShellToken.Info) + }; + var neutral = Variant == CalloutVariant.Default; + + // bg-/8 for the tinted variants, bg-muted/50 for the neutral one. + _tint.Token(BackgroundProperty, neutral ? ShellToken.Muted : accent); + _tint.Opacity = neutral ? 0.5 : 0.08; + + _icon.Kind = Icon == IconName.None ? icon : Icon; + _icon.Token = accent; + _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); + _title.Token(TextBlock.ForegroundProperty, neutral ? ShellToken.Foreground : accent); + _text.Text = Text ?? string.Empty; + _text.IsVisible = !string.IsNullOrEmpty(Text); + _body.Content = Body; + _body.IsVisible = Body != null; + } +} + +public enum CalloutVariant +{ + Info, + Warning, + Danger, + Tip, + Default +} diff --git a/examples/Avalonia.Demo/Components/UI/Carousel.cs b/examples/Avalonia.Demo/Components/UI/Carousel.cs new file mode 100644 index 0000000..87d6a81 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Carousel.cs @@ -0,0 +1,406 @@ +using System; +using Avalonia; +using Avalonia.Animation.Easings; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; +using Avalonia.Threading; + +namespace AvaloniaDemo.Components.UI; + +// Carousel — one slide at a time in a rounded-lg frame; drag, use the round arrow buttons, the +// Left / Right keys or click a dot to move. Each child control is a slide. +// +// +// +// +public class Carousel : Border +{ + public static readonly StyledProperty PositionProperty = + AvaloniaProperty.Register(nameof(Position), defaultBindingMode: BindingMode.TwoWay); + + // At the last slide, Next wraps to the first (and Previous the other way). + public static readonly StyledProperty LoopProperty = + AvaloniaProperty.Register(nameof(Loop), true); + + public static readonly StyledProperty ShowArrowsProperty = + AvaloniaProperty.Register(nameof(ShowArrows), true); + + public static readonly StyledProperty ShowDotsProperty = + AvaloniaProperty.Register(nameof(ShowDots), true); + + public static readonly StyledProperty AutoPlayProperty = + AvaloniaProperty.Register(nameof(AutoPlay)); + + // Milliseconds between slides while AutoPlay is on. + public static readonly StyledProperty AutoPlayIntervalProperty = + AvaloniaProperty.Register(nameof(AutoPlayInterval), 3000); + + // Width / height of the frame. 0 turns it off: set Height on the carousel instead. + public static readonly StyledProperty AspectRatioProperty = + AvaloniaProperty.Register(nameof(AspectRatio), 16d / 9d); + + private const double DragThreshold = 6; + + private readonly Panel _track; + private readonly Border _previous; + private readonly Border _next; + private readonly StackPanel _dots; + private readonly Easing _easing = new CubicEaseOut(); + private DispatcherTimer? _timer; + // Where the track is, in slides: 1.5 is halfway between the second and third slide. + private double _offset; + private int _animation; + private Point? _pressedAt; + private bool _dragging; + private double _dragX; + + static Carousel() + { + PositionProperty.Changed.AddClassHandler((c, _) => c.OnPositionChanged()); + LoopProperty.Changed.AddClassHandler((c, _) => c.UpdateControls()); + ShowArrowsProperty.Changed.AddClassHandler((c, _) => c.UpdateControls()); + ShowDotsProperty.Changed.AddClassHandler((c, _) => c.UpdateControls()); + AutoPlayProperty.Changed.AddClassHandler((c, _) => c.UpdateTimer()); + AutoPlayIntervalProperty.Changed.AddClassHandler((c, _) => c.UpdateTimer()); + AffectsMeasure(AspectRatioProperty); + } + + public int Position + { + get => GetValue(PositionProperty); + set => SetValue(PositionProperty, value); + } + + public bool Loop + { + get => GetValue(LoopProperty); + set => SetValue(LoopProperty, value); + } + + public bool ShowArrows + { + get => GetValue(ShowArrowsProperty); + set => SetValue(ShowArrowsProperty, value); + } + + public bool ShowDots + { + get => GetValue(ShowDotsProperty); + set => SetValue(ShowDotsProperty, value); + } + + public bool AutoPlay + { + get => GetValue(AutoPlayProperty); + set => SetValue(AutoPlayProperty, value); + } + + public int AutoPlayInterval + { + get => GetValue(AutoPlayIntervalProperty); + set => SetValue(AutoPlayIntervalProperty, value); + } + + public double AspectRatio + { + get => GetValue(AspectRatioProperty); + set => SetValue(AspectRatioProperty, value); + } + + [Content] + public Controls Slides => _track.Children; + + public event EventHandler? PositionChanged; + + public Carousel() + { + // Transparent, not null, so a drag can start between the slides' own content. + _track = new Panel { ClipToBounds = true, Background = Brushes.Transparent }; + _track.Children.CollectionChanged += (_, _) => OnSlidesChanged(); + _track.SizeChanged += (_, _) => Render(); + _track.PointerPressed += OnTrackPressed; + _track.PointerMoved += OnTrackMoved; + _track.PointerReleased += OnTrackReleased; + _track.PointerCaptureLost += (_, _) => EndDrag(cancelled: true); + + _previous = Arrow(IconName.ChevronLeft, HorizontalAlignment.Left, "Previous slide", Previous); + _next = Arrow(IconName.ChevronRight, HorizontalAlignment.Right, "Next slide", Next); + foreach (var arrow in new[] { _previous, _next }) + { + arrow.PointerEntered += (_, _) => UpdateControls(); + arrow.PointerExited += (_, _) => UpdateControls(); + } + _dots = new StackPanel + { + Orientation = Orientation.Horizontal, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Bottom, + Margin = new Thickness(0, 0, 0, 8) + }; + + Child = new Panel { Children = { _track, _previous, _next, _dots } }; + CornerRadius = new CornerRadius(ShellTheme.RadiusLg); + // Slides stay inside the rounded corners. + ClipToBounds = true; + UpdateControls(); + } + + private int Count => _track.Children.Count; + private int Current => Math.Clamp(Position, 0, Math.Max(0, Count - 1)); + + public void Next() + { + if (Current < Count - 1) Position = Current + 1; + else if (Loop && Count > 1) Position = 0; + } + + public void Previous() + { + if (Current > 0) Position = Current - 1; + else if (Loop && Count > 1) Position = Count - 1; + } + + // The frame takes the width it is given and derives its height from AspectRatio. + protected override Size MeasureOverride(Size availableSize) + { + if (AspectRatio <= 0) return base.MeasureOverride(availableSize); + var width = double.IsInfinity(availableSize.Width) ? base.MeasureOverride(availableSize).Width : availableSize.Width; + var size = new Size(width, width / AspectRatio); + base.MeasureOverride(size); + return size; + } + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + UpdateTimer(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + StopTimer(); + _animation++; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Handled || e.Source is TextBox) return; + if (e.Key == Key.Left) Previous(); + else if (e.Key == Key.Right) Next(); + else return; + e.Handled = true; + } + + // Round outline button over the slides; a tab stop that activates with Enter or Space. + private static Border Arrow(IconName icon, HorizontalAlignment side, string name, Action press) + { + var shadow = new BoxShadows(new BoxShadow { OffsetY = 2, Blur = 6, Color = ShellTheme.Shadow(0.15) }); + var button = new Border + { + Child = new Icon { Kind = icon, Size = 16, Token = ShellToken.Foreground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }, + Width = 32, + Height = 32, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(16), + HorizontalAlignment = side, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(12, 0), + BoxShadow = shadow, + Cursor = new Cursor(StandardCursorType.Hand) + }; + button.Token(BackgroundProperty, ShellToken.Background).Token(BorderBrushProperty, ShellToken.Border); + AutomationProperties.SetName(button, name); + ShellFocus.Ring(button, button); + // The focus ring is painted as a shadow and cleared on blur: put the drop shadow back. + button.LostFocus += (_, _) => button.BoxShadow = shadow; + button.PointerPressed += (_, e) => e.Handled = true; + button.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + press(); + e.Handled = true; + }; + button.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + press(); + e.Handled = true; + }; + return button; + } + + private void OnSlidesChanged() + { + _dots.Children.Clear(); + for (var i = 0; i < Count; i++) + { + var index = i; + var dot = new Border + { + Width = 8, + Height = 8, + CornerRadius = new CornerRadius(4), + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + dot.Token(BackgroundProperty, ShellToken.Primary); + // 8px dot in a 16px click target. + var target = new Border { Width = 16, Height = 16, Background = Brushes.Transparent, Child = dot, Cursor = new Cursor(StandardCursorType.Hand) }; + AutomationProperties.SetName(target, $"Go to slide {i + 1}"); + target.PointerPressed += (_, e) => e.Handled = true; + target.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + Position = index; + e.Handled = true; + }; + _dots.Children.Add(target); + } + _animation++; + _offset = Current; + Render(); + UpdateControls(); + UpdateTimer(); + } + + private void OnPositionChanged() + { + UpdateControls(); + AnimateTo(Current, 300); + PositionChanged?.Invoke(this, Current); + // A manual move restarts the auto-play countdown. + UpdateTimer(); + } + + // Moves the track to a slide over the next frames; a newer move or a drag takes over. + private void AnimateTo(double target, int milliseconds) + { + var run = ++_animation; + var from = _offset; + if (TopLevel.GetTopLevel(this) is not { } top || from == target) + { + _offset = target; + Render(); + return; + } + + TimeSpan? started = null; + void Frame(TimeSpan now) + { + if (run != _animation) return; + started ??= now; + var progress = Math.Min(1, (now - started.Value).TotalMilliseconds / milliseconds); + _offset = from + (target - from) * _easing.Ease(progress); + Render(); + if (progress < 1) top.RequestAnimationFrame(Frame); + } + top.RequestAnimationFrame(Frame); + } + + // Slides are stacked in one cell and spread out sideways by translation. + private void Render() + { + var width = _track.Bounds.Width; + if (width <= 0) return; + for (var i = 0; i < Count; i++) + { + var x = (i - _offset) * width; + if (_track.Children[i].RenderTransform is TranslateTransform shift) shift.X = x; + else _track.Children[i].RenderTransform = new TranslateTransform(x, 0); + } + } + + private void UpdateControls() + { + var current = Current; + var many = Count > 1; + var canGoBack = Loop || current > 0; + var canGoOn = Loop || current < Count - 1; + _previous.IsVisible = ShowArrows && many; + _next.IsVisible = ShowArrows && many; + _previous.Opacity = !canGoBack ? 0.4 : _previous.IsPointerOver ? 1 : 0.9; + _next.Opacity = !canGoOn ? 0.4 : _next.IsPointerOver ? 1 : 0.9; + _previous.Focusable = canGoBack; + _next.Focusable = canGoOn; + _dots.IsVisible = ShowDots && many; + for (var i = 0; i < _dots.Children.Count; i++) + { + // One color at two strengths, so the dots stay readable over any slide. + if (_dots.Children[i] is Border { Child: { } dot }) + dot.Opacity = i == current ? 1 : 0.3; + } + } + + private void OnTrackPressed(object? sender, PointerPressedEventArgs e) + { + if (Count < 2 || !e.GetCurrentPoint(_track).Properties.IsLeftButtonPressed) return; + _pressedAt = e.GetPosition(_track); + _dragging = false; + _dragX = 0; + } + + private void OnTrackMoved(object? sender, PointerEventArgs e) + { + var width = _track.Bounds.Width; + if (_pressedAt is not { } start || width <= 0) return; + _dragX = e.GetPosition(_track).X - start.X; + if (!_dragging) + { + // A small move is still a click on the slide's own content. + if (Math.Abs(_dragX) < DragThreshold) return; + _dragging = true; + _animation++; + e.Pointer.Capture(_track); + } + // Follow the pointer, with a little give past the first and last slide. + _offset = Math.Clamp(Current - _dragX / width, -0.25, Count - 0.75); + Render(); + e.Handled = true; + } + + private void OnTrackReleased(object? sender, PointerReleasedEventArgs e) + { + if (_dragging) e.Handled = true; + EndDrag(cancelled: false); + e.Pointer.Capture(null); + } + + private void EndDrag(bool cancelled) + { + var width = _track.Bounds.Width; + var wasDragging = _dragging; + var moved = width > 0 ? _dragX / width : 0; + _pressedAt = null; + _dragging = false; + _dragX = 0; + if (!wasDragging) return; + + var current = Current; + if (!cancelled && moved <= -0.2 && current < Count - 1) Position = current + 1; + else if (!cancelled && moved >= 0.2 && current > 0) Position = current - 1; + // Not far enough: settle back. + else AnimateTo(current, 200); + } + + private void UpdateTimer() + { + StopTimer(); + if (!AutoPlay || Count < 2 || TopLevel.GetTopLevel(this) is null) return; + _timer = new DispatcherTimer(TimeSpan.FromMilliseconds(Math.Max(500, AutoPlayInterval)), DispatcherPriority.Normal, + (_, _) => Position = Current < Count - 1 ? Current + 1 : 0); + _timer.Start(); + } + + private void StopTimer() + { + _timer?.Stop(); + _timer = null; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/ContextMenu.cs b/examples/Avalonia.Demo/Components/UI/ContextMenu.cs new file mode 100644 index 0000000..4218144 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/ContextMenu.cs @@ -0,0 +1,238 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Context menu — right-click the trigger (long-press on touch, or the Menu key / Shift+F10) to +// open a menu at the pointer. Clicking outside, picking an item or pressing Escape closes it. +// +// ...any control... +// +// +// +// +// +// +public class ContextMenu : ShellPopoverHost +{ + private bool _atPoint; + + public ContextMenu() + { + // The trigger usually is a surface (a card, a row): let it fill its container. + HorizontalAlignment = HorizontalAlignment.Stretch; + // Clicks on the trigger reach it while the menu is open, so a second right-click moves the menu. + Popup.OverlayInputPassThroughElement = this; + } + + protected override bool IsContent(Control child) => child is ContextMenuContent; + protected override double Offset => _atPoint ? 0 : 4; + + // Opens the menu at a point relative to this control, or below the trigger when null. + public void OpenAt(Point? point) + { + _atPoint = point.HasValue; + Popup.PlacementRect = point is { } at ? new Rect(at, new Size(1, 1)) : null; + if (IsOpen) Popup.VerticalOffset = Offset; + else IsOpen = true; + } +} + +// The surface that opens the enclosing ContextMenu. +public class ContextMenuTrigger : Border +{ + public ContextMenuTrigger() + { + // Transparent, not null, so empty areas of the surface take the right-click too. + Background = Brushes.Transparent; + AddHandler(ContextRequestedEvent, OnContextRequested); + } + + private void OnContextRequested(object? sender, ContextRequestedEventArgs e) + { + if (this.FindParentOfType() is not { } menu) return; + menu.OpenAt(e.TryGetPosition(menu, out var point) ? point : null); + e.Handled = true; + } + + // A long-press on touch opens the menu under the trigger. + protected override void OnHolding(HoldingRoutedEventArgs e) + { + base.OnHolding(e); + if (e.HoldingState != HoldingState.Started || this.FindParentOfType() is not { IsOpen: false } menu) return; + menu.OpenAt(null); + e.Handled = true; + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + this.FindParentOfType()?.Close(); + } +} + +// Menu panel — min-w-[13rem] rounded-md border bg-popover p-1 shadow-md. XAML children go to Items. +public class ContextMenuContent : Border +{ + private readonly StackPanel _stack = new(); + + [Content] + public Controls Items => _stack.Children; + + public ContextMenuContent() + { + Child = _stack; + Padding = new Thickness(4); + MinWidth = 208; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + BoxShadow = ShellPopups.PanelShadow(); + this.Token(BackgroundProperty, ShellToken.Popover).Token(BorderBrushProperty, ShellToken.Border); + } +} + +// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint. +public class ContextMenuItem : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + // Keyboard hint shown at the end of the row (display only). + public static readonly StyledProperty ShortcutProperty = + AvaloniaProperty.Register(nameof(Shortcut)); + + public static readonly StyledProperty IsDestructiveProperty = + AvaloniaProperty.Register(nameof(IsDestructive)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private readonly TextBlock _shortcut; + + static ContextMenuItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + ShortcutProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsDestructiveProperty.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 IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string? Shortcut + { + get => GetValue(ShortcutProperty); + set => SetValue(ShortcutProperty, value); + } + + public bool IsDestructive + { + get => GetValue(IsDestructiveProperty); + set => SetValue(IsDestructiveProperty, value); + } + + public event EventHandler? Clicked; + + public ContextMenuItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalAlignment = VerticalAlignment.Center }; + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center }; + _shortcut = new TextBlock { FontSize = 12, VerticalAlignment = VerticalAlignment.Center, IsVisible = false }; + _shortcut.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*,Auto"), ColumnSpacing = 8 }; + Grid.SetColumn(_label, 1); + Grid.SetColumn(_shortcut, 2); + grid.Children.Add(_icon); + grid.Children.Add(_label); + grid.Children.Add(_shortcut); + + Child = grid; + Height = 32; + Padding = new Thickness(8, 0); + CornerRadius = new CornerRadius(ShellTheme.RadiusSm); + Background = Brushes.Transparent; + Cursor = new Cursor(StandardCursorType.Hand); + 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.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + + 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; + } + + // Picks the item in code, as a click or Enter/Space would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + this.FindParentOfType()?.Close(); + Clicked?.Invoke(this, EventArgs.Empty); + } + + private void Update() + { + var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground; + _icon.Kind = Icon; + _icon.IsVisible = Icon != IconName.None; + _icon.Token = color; + _label.Text = Text ?? string.Empty; + _label.Token(TextBlock.ForegroundProperty, color); + _shortcut.Text = Shortcut ?? string.Empty; + _shortcut.IsVisible = !string.IsNullOrEmpty(Shortcut); + } +} + +// Divider between groups of items — -mx-1 my-1 h-px bg-border. +public class ContextMenuSeparator : Border +{ + public ContextMenuSeparator() + { + Height = 1; + Margin = new Thickness(-4, 4); + this.Token(BackgroundProperty, ShellToken.Border); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/EmptyState.cs b/examples/Avalonia.Demo/Components/UI/EmptyState.cs new file mode 100644 index 0000000..235327b --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/EmptyState.cs @@ -0,0 +1,123 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Placeholder for an empty list or screen: icon in a muted tile, title, description and +// optional actions, centered. Bordered="True" adds shadcn's dashed outline. +// +// +// +public class EmptyState : Border +{ + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), IconName.Inbox); + + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public static readonly StyledProperty BorderedProperty = + AvaloniaProperty.Register(nameof(Bordered)); + + private readonly Rectangle _outline; + private readonly Icon _icon; + private readonly TextBlock _title; + private readonly TextBlock _description; + private readonly StackPanel _actions; + + static EmptyState() + { + IconProperty.Changed.AddClassHandler((s, _) => s.Update()); + TitleProperty.Changed.AddClassHandler((s, _) => s.Update()); + DescriptionProperty.Changed.AddClassHandler((s, _) => s.Update()); + BorderedProperty.Changed.AddClassHandler((s, _) => s.Update()); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } + + public bool Bordered + { + get => GetValue(BorderedProperty); + set => SetValue(BorderedProperty, value); + } + + // The action buttons, in a centered row. + [Content] + public Controls Actions => _actions.Children; + + public EmptyState() + { + _icon = new Icon { Size = 24, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + var badge = new Border + { + Child = _icon, + Width = 48, + Height = 48, + CornerRadius = new CornerRadius(ShellTheme.RadiusLg), + HorizontalAlignment = HorizontalAlignment.Center + }; + badge.Token(BackgroundProperty, ShellToken.Muted); + + _title = new TextBlock { FontSize = 18, FontWeight = FontWeight.SemiBold, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap }; + _title.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + _description = new TextBlock + { + FontSize = 14, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + HorizontalAlignment = HorizontalAlignment.Center, + MaxWidth = 360 + }; + _description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + _actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = HorizontalAlignment.Center, Margin = new Thickness(0, 8, 0, 0) }; + + // Border can't dash its stroke, so the outline is a shape behind the content. + _outline = new Rectangle + { + StrokeThickness = 1, + StrokeDashArray = new AvaloniaList { 4, 4 }, + RadiusX = ShellTheme.RadiusLg, + RadiusY = ShellTheme.RadiusLg + }; + _outline.Token(Shape.StrokeProperty, ShellToken.Border); + + var content = new StackPanel { Spacing = 8, Margin = new Thickness(24, 32), Children = { badge, _title, _description, _actions } }; + Child = new Panel { Children = { _outline, content } }; + Update(); + } + + private void Update() + { + _icon.Kind = Icon; + _title.Text = Title ?? string.Empty; + _title.IsVisible = !string.IsNullOrEmpty(Title); + _description.Text = Description ?? string.Empty; + _description.IsVisible = !string.IsNullOrEmpty(Description); + _outline.IsVisible = Bordered; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Pagination.cs b/examples/Avalonia.Demo/Components/UI/Pagination.cs new file mode 100644 index 0000000..b870ab2 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Pagination.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +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; + +// Page navigation — Previous / 1 … 4 [5] 6 … 20 / Next. Page buttons are 36px ghost buttons; +// the current page is outlined. Collapses long ranges into ellipses around the current page. +// Every item is a tab stop that activates with Enter or Space. +// Usage: +public class Pagination : Border +{ + public static readonly StyledProperty PageProperty = + AvaloniaProperty.Register(nameof(Page), 1, defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty TotalPagesProperty = + AvaloniaProperty.Register(nameof(TotalPages), 1); + + // Pages shown on each side of the current page. + public static readonly StyledProperty SiblingCountProperty = + AvaloniaProperty.Register(nameof(SiblingCount), 1); + + // "Previous" / "Next" text next to the chevrons. + public static readonly StyledProperty ShowLabelsProperty = + AvaloniaProperty.Register(nameof(ShowLabels), true); + + private const double ItemSize = 36; + + private readonly StackPanel _row = new() { Orientation = Orientation.Horizontal, Spacing = 4 }; + private Control? _currentItem; + + static Pagination() + { + PageProperty.Changed.AddClassHandler((p, _) => + { + p.Rebuild(); + p.PageChanged?.Invoke(p, p.Page); + }); + TotalPagesProperty.Changed.AddClassHandler((p, _) => p.Rebuild()); + SiblingCountProperty.Changed.AddClassHandler((p, _) => p.Rebuild()); + ShowLabelsProperty.Changed.AddClassHandler((p, _) => p.Rebuild()); + } + + public int Page + { + get => GetValue(PageProperty); + set => SetValue(PageProperty, value); + } + + public int TotalPages + { + get => GetValue(TotalPagesProperty); + set => SetValue(TotalPagesProperty, value); + } + + public int SiblingCount + { + get => GetValue(SiblingCountProperty); + set => SetValue(SiblingCountProperty, value); + } + + public bool ShowLabels + { + get => GetValue(ShowLabelsProperty); + set => SetValue(ShowLabelsProperty, value); + } + + public event EventHandler? PageChanged; + + public Pagination() + { + Child = _row; + HorizontalAlignment = HorizontalAlignment.Left; + Rebuild(); + } + + // Page is not clamped when set (XAML may set Page before TotalPages), only when shown. + private int Total => Math.Max(1, TotalPages); + private int Current => Math.Clamp(Page, 1, Total); + + // First, last, and the current page with its siblings; 0 marks an ellipsis. + private List VisiblePages() + { + var total = Total; + var siblings = Math.Max(0, SiblingCount); + var pages = new List(); + var from = Math.Max(2, Current - siblings); + var to = Math.Min(total - 1, Current + siblings); + + pages.Add(1); + if (from > 2) pages.Add(from == 3 ? 2 : 0); + for (var p = from; p <= to; p++) pages.Add(p); + if (to < total - 1) pages.Add(to == total - 2 ? total - 1 : 0); + if (total > 1) pages.Add(total); + return pages; + } + + private void Rebuild() + { + // The rebuild replaces the focused item, so keyboard focus moves to the new current page. + var hadFocus = _row.IsKeyboardFocusWithin; + _row.Children.Clear(); + _row.Children.Add(NavItem(IconName.ChevronLeft, "Previous", iconFirst: true, enabled: Current > 1, () => Page = Current - 1)); + foreach (var page in VisiblePages()) + _row.Children.Add(page == 0 ? Ellipsis() : PageItem(page)); + _row.Children.Add(NavItem(IconName.ChevronRight, "Next", iconFirst: false, enabled: Current < Total, () => Page = Current + 1)); + if (hadFocus) _currentItem?.Focus(NavigationMethod.Tab); + } + + private Control PageItem(int page) + { + var current = page == Current; + var label = new TextBlock { Text = page.ToString(CultureInfo.CurrentCulture), FontSize = 14, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + var item = Item(label, ItemSize, new Thickness(0), current ? null : () => Page = page, focusable: true); + AutomationProperties.SetName(item, current ? $"Page {page}, current page" : $"Go to page {page}"); + if (current) + { + // Outline variant for the current page; no hover change. + item.BorderThickness = new Thickness(1); + item.Token(BorderBrushProperty, ShellToken.Input).Token(BackgroundProperty, ShellToken.Background); + _currentItem = item; + } + return item; + } + + private Control NavItem(IconName icon, string text, bool iconFirst, bool enabled, Action go) + { + var chevron = new Icon { Kind = icon, Size = 16, VerticalAlignment = VerticalAlignment.Center }; + var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 4, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + if (ShowLabels) + { + var label = new TextBlock { Text = text, FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center }; + label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + if (iconFirst) { row.Children.Add(chevron); row.Children.Add(label); } + else { row.Children.Add(label); row.Children.Add(chevron); } + } + else row.Children.Add(chevron); + + var item = Item(row, double.NaN, new Thickness(ShowLabels ? 10 : 0, 0), enabled ? go : null, focusable: enabled); + AutomationProperties.SetName(item, $"Go to {text.ToLowerInvariant()} page"); + if (!enabled) item.Opacity = 0.5; + return item; + } + + private static Control Ellipsis() => new Border + { + Width = ItemSize, + Height = ItemSize, + Child = new Icon { Kind = IconName.Ellipsis, Size = 16, Token = ShellToken.MutedForeground, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center } + }; + + // Ghost button: transparent, hover:bg-accent. With no action it doesn't react (the current + // page, or Previous / Next at the ends); the current page is still a tab stop. + private static Border Item(Control content, double width, Thickness padding, Action? onPress, bool focusable) + { + var item = new Border + { + Child = content, + Width = width, + MinWidth = ItemSize, + Height = ItemSize, + Padding = padding, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + Background = Brushes.Transparent + }; + if (focusable) ShellFocus.Ring(item, item); + if (onPress is null) return item; + + item.Cursor = new Cursor(StandardCursorType.Hand); + item.PointerEntered += (_, _) => item.Token(BackgroundProperty, ShellToken.Accent); + item.PointerExited += (_, _) => { item.ClearToken(BackgroundProperty); item.Background = Brushes.Transparent; }; + item.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + onPress(); + e.Handled = true; + }; + item.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + onPress(); + e.Handled = true; + }; + return item; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Stepper.cs b/examples/Avalonia.Demo/Components/UI/Stepper.cs new file mode 100644 index 0000000..f30fd31 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Stepper.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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; +using AvaloniaDemo.Components.UI.Variants; + +namespace AvaloniaDemo.Components.UI; + +// Step-by-step flow — numbered circles joined by lines (active filled, completed checked), the +// active step's content, and Previous / Next / Confirm buttons. A step becomes clickable (and a +// tab stop) once it has been reached. +// +// ...any control... +// ... +// ... +// +public class Stepper : Border +{ + public static readonly StyledProperty CurrentStepProperty = + AvaloniaProperty.Register(nameof(CurrentStep), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty ShowNavigationProperty = + AvaloniaProperty.Register(nameof(ShowNavigation), true); + + public static readonly StyledProperty PreviousTextProperty = + AvaloniaProperty.Register(nameof(PreviousText), "Previous"); + + public static readonly StyledProperty NextTextProperty = + AvaloniaProperty.Register(nameof(NextText), "Next"); + + public static readonly StyledProperty ConfirmTextProperty = + AvaloniaProperty.Register(nameof(ConfirmText), "Confirm"); + + private const double CircleSize = 32; + + private sealed class Marker + { + public Marker(Border circle, TextBlock number, Icon check, TextBlock title, Border before, Border after) + { + Circle = circle; + Number = number; + Check = check; + Title = title; + Before = before; + After = after; + } + + public Border Circle { get; } + public TextBlock Number { get; } + public Icon Check { get; } + public TextBlock Title { get; } + public Border Before { get; } + public Border After { get; } + } + + private readonly List _markers = new(); + private readonly Grid _header = new(); + private readonly Panel _body = new() { Margin = new Thickness(0, 24, 0, 0) }; + private readonly Panel _navigation; + private readonly Button _previous; + private readonly Button _next; + private int _highest; + + static Stepper() + { + CurrentStepProperty.Changed.AddClassHandler((s, _) => s.OnCurrentStepChanged()); + ShowNavigationProperty.Changed.AddClassHandler((s, _) => s.UpdateState()); + PreviousTextProperty.Changed.AddClassHandler((s, _) => s.UpdateState()); + NextTextProperty.Changed.AddClassHandler((s, _) => s.UpdateState()); + ConfirmTextProperty.Changed.AddClassHandler((s, _) => s.UpdateState()); + } + + public int CurrentStep + { + get => GetValue(CurrentStepProperty); + set => SetValue(CurrentStepProperty, value); + } + + public bool ShowNavigation + { + get => GetValue(ShowNavigationProperty); + set => SetValue(ShowNavigationProperty, value); + } + + public string PreviousText + { + get => GetValue(PreviousTextProperty); + set => SetValue(PreviousTextProperty, value); + } + + public string NextText + { + get => GetValue(NextTextProperty); + set => SetValue(NextTextProperty, value); + } + + public string ConfirmText + { + get => GetValue(ConfirmTextProperty); + set => SetValue(ConfirmTextProperty, value); + } + + // The StepperStep children. Every step stays in the tree; only the active one is visible. + [Content] + public Controls Steps => _body.Children; + + public event EventHandler? CurrentStepChanged; + // Raised by the Confirm button on the last step. + public event EventHandler? Confirmed; + + public Stepper() + { + _previous = new Button { Variant = ButtonVariant.Outline, HorizontalAlignment = HorizontalAlignment.Left }; + _previous.Clicked += (_, _) => Previous(); + _next = new Button { HorizontalAlignment = HorizontalAlignment.Right }; + _next.Clicked += (_, _) => + { + if (IsLast) + { + _highest = Math.Max(_highest, StepList.Count - 1); + Confirmed?.Invoke(this, EventArgs.Empty); + } + else Next(); + }; + _navigation = new Panel { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } }; + + Child = new StackPanel { Children = { _header, _body, _navigation } }; + _body.Children.CollectionChanged += (_, _) => Rebuild(); + Rebuild(); + } + + private List StepList => _body.Children.OfType().ToList(); + private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, StepList.Count - 1)); + private bool IsLast => Current >= StepList.Count - 1; + + public void Next() + { + if (!IsLast) CurrentStep = Current + 1; + } + + public void Previous() + { + if (Current > 0) CurrentStep = Current - 1; + } + + private void OnCurrentStepChanged() + { + _highest = Math.Max(_highest, Current); + UpdateState(); + CurrentStepChanged?.Invoke(this, Current); + } + + private void Rebuild() + { + var steps = StepList; + _header.Children.Clear(); + _header.ColumnDefinitions.Clear(); + _markers.Clear(); + + for (var i = 0; i < steps.Count; i++) + { + var index = i; + var step = steps[i]; + step.PropertyChanged -= OnStepPropertyChanged; + step.PropertyChanged += OnStepPropertyChanged; + + var number = new TextBlock + { + Text = (i + 1).ToString(CultureInfo.CurrentCulture), + FontSize = 14, + FontWeight = FontWeight.Bold, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center + }; + var check = new Icon { Kind = IconName.Check, Size = 16, Token = ShellToken.Primary, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; + var circle = new Border + { + Child = new Panel { Children = { number, check } }, + Width = CircleSize, + Height = CircleSize, + BorderThickness = new Thickness(2), + CornerRadius = new CornerRadius(CircleSize / 2) + }; + AutomationProperties.SetName(circle, string.IsNullOrEmpty(step.Title) ? $"Step {i + 1}" : step.Title); + ShellFocus.Ring(circle, circle); + circle.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left || index > _highest) return; + CurrentStep = index; + e.Handled = true; + }; + circle.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space) || index > _highest) return; + CurrentStep = index; + e.Handled = true; + }; + + // Lines run from each circle to the edges of its column, so neighbors meet. + var before = Line(i > 0); + var after = Line(i < steps.Count - 1); + var rail = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto,*") }; + Grid.SetColumn(circle, 1); + Grid.SetColumn(after, 2); + rail.Children.Add(before); + rail.Children.Add(circle); + rail.Children.Add(after); + + var title = new TextBlock + { + Text = step.Title, + FontSize = 14, + FontWeight = FontWeight.Bold, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(4, 8, 4, 0), + IsVisible = !string.IsNullOrEmpty(step.Title) + }; + var description = new TextBlock + { + Text = step.Description, + FontSize = 12, + TextAlignment = TextAlignment.Center, + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(4, 2, 4, 0), + IsVisible = !string.IsNullOrEmpty(step.Description) + }; + description.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + var column = new StackPanel { Children = { rail, title, description } }; + Grid.SetColumn(column, i); + _header.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + _header.Children.Add(column); + _markers.Add(new Marker(circle, number, check, title, before, after)); + } + + _highest = Math.Min(_highest, Math.Max(0, steps.Count - 1)); + UpdateState(); + + static Border Line(bool visible) => new() + { + Height = 2, + VerticalAlignment = VerticalAlignment.Center, + Opacity = visible ? 1 : 0 + }; + } + + private void OnStepPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e) + { + if (e.Property == StepperStep.TitleProperty || e.Property == StepperStep.DescriptionProperty) Rebuild(); + } + + private void UpdateState() + { + var steps = StepList; + var current = Current; + for (var i = 0; i < _markers.Count; i++) + { + var marker = _markers[i]; + var active = i == current; + var completed = i < _highest && !active; + var reached = i <= _highest; + + if (active) marker.Circle.Token(BackgroundProperty, ShellToken.Primary); + else + { + // Transparent, not null, so the whole circle stays hit-testable. + marker.Circle.ClearToken(BackgroundProperty); + marker.Circle.Background = Brushes.Transparent; + } + marker.Circle.Token(BorderBrushProperty, active || completed ? ShellToken.Primary : ShellToken.Border); + marker.Circle.Focusable = reached; + marker.Circle.Cursor = reached ? new Cursor(StandardCursorType.Hand) : null; + marker.Number.IsVisible = !completed; + marker.Number.Token(TextBlock.ForegroundProperty, active ? ShellToken.PrimaryForeground : ShellToken.MutedForeground); + marker.Check.IsVisible = completed; + marker.Title.Token(TextBlock.ForegroundProperty, active || completed ? ShellToken.Foreground : ShellToken.MutedForeground); + marker.Before.Token(BackgroundProperty, reached ? ShellToken.Primary : ShellToken.Border); + marker.After.Token(BackgroundProperty, i < _highest ? ShellToken.Primary : ShellToken.Border); + } + + for (var i = 0; i < steps.Count; i++) + steps[i].IsVisible = i == current; + + _navigation.IsVisible = ShowNavigation && steps.Count > 0; + _previous.Text = PreviousText; + _previous.IsEnabled = current > 0; + _next.Text = IsLast ? ConfirmText : NextText; + } +} + +// One step of a Stepper: its Title and Description label the step, its content shows while the +// step is active. +public class StepperStep : Border +{ + public static readonly StyledProperty TitleProperty = + AvaloniaProperty.Register(nameof(Title)); + + public static readonly StyledProperty DescriptionProperty = + AvaloniaProperty.Register(nameof(Description)); + + public string? Title + { + get => GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string? Description + { + get => GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Table.cs b/examples/Avalonia.Demo/Components/UI/Table.cs new file mode 100644 index 0000000..bd7b0a7 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Table.cs @@ -0,0 +1,338 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Metadata; + +namespace AvaloniaDemo.Components.UI; + +// Data table — text-sm rows inside a rounded-md border: a header row in the muted foreground, +// rows divided by border-b that tint on hover, optional caption. +// +// +// ... +// +// +// ... +// +// +// Columns takes Grid column widths. Use star and fixed widths: every row is its own grid, so an +// Auto column would size per row. For long lists, use TableRow (with its own Columns) as the item +// template of an ItemsControl under a TableHeader. +public class Table : Border +{ + public static readonly StyledProperty ColumnsProperty = + AvaloniaProperty.Register(nameof(Columns)); + + public static readonly StyledProperty CaptionProperty = + AvaloniaProperty.Register(nameof(Caption)); + + public static readonly StyledProperty BorderedProperty = + AvaloniaProperty.Register(nameof(Bordered), true); + + // Narrower than this, the table scrolls sideways instead of squeezing its columns. + public static readonly StyledProperty MinimumContentWidthProperty = + AvaloniaProperty.Register(nameof(MinimumContentWidth)); + + private readonly StackPanel _rows = new(); + private readonly ScrollViewer _scroll; + private readonly Border _frame; + private readonly TextBlock _caption; + + static Table() + { + ColumnsProperty.Changed.AddClassHandler((t, _) => t.Refresh()); + CaptionProperty.Changed.AddClassHandler
((t, _) => t.UpdateChrome()); + BorderedProperty.Changed.AddClassHandler
((t, _) => t.UpdateChrome()); + MinimumContentWidthProperty.Changed.AddClassHandler
((t, _) => t.UpdateScrolling()); + } + + public string? Columns + { + get => GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + + public string? Caption + { + get => GetValue(CaptionProperty); + set => SetValue(CaptionProperty, value); + } + + public bool Bordered + { + get => GetValue(BorderedProperty); + set => SetValue(BorderedProperty, value); + } + + public double MinimumContentWidth + { + get => GetValue(MinimumContentWidthProperty); + set => SetValue(MinimumContentWidthProperty, value); + } + + [Content] + public Controls Rows => _rows.Children; + + public Table() + { + _rows.Children.CollectionChanged += (_, _) => Refresh(); + _scroll = new ScrollViewer + { + Content = _rows, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled + }; + _scroll.SizeChanged += (_, _) => FitScrollContent(); + + _frame = new Border + { + Child = _scroll, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + // Row tints stay inside the rounded corners. + ClipToBounds = true + }; + _frame.Token(BorderBrushProperty, ShellToken.Border); + + _caption = new TextBlock { FontSize = 14, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) }; + _caption.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + Child = new StackPanel { Children = { _frame, _caption } }; + UpdateChrome(); + } + + // Parses "2*,*,100" into column widths. Empty means equal star columns. + internal static IReadOnlyList ParseColumns(string? columns) + { + if (string.IsNullOrWhiteSpace(columns)) return Array.Empty(); + return columns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ParseWidth) + .ToList(); + + static GridLength ParseWidth(string part) + { + if (part.Equals("auto", StringComparison.OrdinalIgnoreCase)) return GridLength.Auto; + if (part.EndsWith('*')) + { + var factor = part[..^1]; + return new GridLength(factor.Length > 0 && double.TryParse(factor, NumberStyles.Float, CultureInfo.InvariantCulture, out var stars) ? stars : 1, GridUnitType.Star); + } + return double.TryParse(part, NumberStyles.Float, CultureInfo.InvariantCulture, out var width) ? new GridLength(width) : GridLength.Star; + } + } + + // The table hands rows their columns and tells the last one to drop its divider. + private void Refresh() + { + var widths = ParseColumns(Columns); + var rows = _rows.Children.OfType().ToList(); + for (var i = 0; i < rows.Count; i++) + { + if (string.IsNullOrWhiteSpace(rows[i].Columns)) rows[i].Apply(widths); + rows[i].ShowDivider = i < rows.Count - 1; + } + } + + private void UpdateChrome() + { + _frame.BorderThickness = new Thickness(Bordered ? 1 : 0); + _caption.Text = Caption ?? string.Empty; + _caption.IsVisible = !string.IsNullOrEmpty(Caption); + } + + private void UpdateScrolling() + { + _scroll.HorizontalScrollBarVisibility = MinimumContentWidth > 0 ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; + FitScrollContent(); + } + + // A sideways ScrollViewer measures rows at their smallest width; give them a real one. + private void FitScrollContent() + { + _rows.Width = MinimumContentWidth > 0 && _scroll.Bounds.Width > 0 + ? Math.Max(_scroll.Bounds.Width, MinimumContentWidth) + : double.NaN; + } +} + +// Body row — border-b, hover:bg-muted/50, bg-muted when selected. Its children are the cells, +// placed in columns in order. Handle clicks and taps with Avalonia's own Tapped event. +public class TableRow : Grid +{ + // Set only for rows used outside a Table (e.g. an ItemsControl item template). + public static readonly StyledProperty ColumnsProperty = + AvaloniaProperty.Register(nameof(Columns)); + + public static readonly StyledProperty IsSelectedProperty = + AvaloniaProperty.Register(nameof(IsSelected)); + + private readonly Border _fill = new() { Opacity = 0, IsHitTestVisible = false, ZIndex = -1 }; + private readonly Border _divider = new() { Height = 1, VerticalAlignment = VerticalAlignment.Bottom, IsHitTestVisible = false }; + private IReadOnlyList _widths = Array.Empty(); + + static TableRow() + { + ColumnsProperty.Changed.AddClassHandler((r, _) => r.Apply(Table.ParseColumns(r.Columns))); + IsSelectedProperty.Changed.AddClassHandler((r, _) => r.UpdateFill()); + } + + public string? Columns + { + get => GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + + public bool IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + internal bool ShowDivider + { + get => _divider.IsVisible; + set => _divider.IsVisible = value; + } + + protected virtual bool Interactive => true; + + public TableRow() + { + _fill.Token(Border.BackgroundProperty, ShellToken.Muted); + _divider.Token(Border.BackgroundProperty, ShellToken.Border); + Children.Add(_fill); + Children.Add(_divider); + // A panel with no background only takes pointer input over its children. + Background = Brushes.Transparent; + Children.CollectionChanged += (_, _) => Apply(_widths); + if (Interactive) Cursor = new Cursor(StandardCursorType.Hand); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + UpdateFill(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + UpdateFill(); + } + + internal void Apply(IReadOnlyList widths) + { + _widths = widths; + var cells = Children.Where(c => c != _fill && c != _divider).ToList(); + var count = Math.Max(1, Math.Max(cells.Count, widths.Count)); + + ColumnDefinitions.Clear(); + for (var i = 0; i < count; i++) + ColumnDefinitions.Add(new ColumnDefinition(i < widths.Count ? widths[i] : GridLength.Star)); + for (var i = 0; i < cells.Count; i++) + SetColumn(cells[i], i); + SetColumnSpan(_fill, count); + SetColumnSpan(_divider, count); + } + + private void UpdateFill() => _fill.Opacity = IsSelected ? 1 : Interactive && IsPointerOver ? 0.5 : 0; +} + +// Header row: holds TableHead cells, no hover. +public class TableHeader : TableRow +{ + protected override bool Interactive => false; +} + +// Header cell — h-11 px-4 font-medium text-muted-foreground. +public class TableHead : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty HorizontalTextAlignmentProperty = + AvaloniaProperty.Register(nameof(HorizontalTextAlignment), TextAlignment.Start); + + private readonly TextBlock _label; + + static TableHead() + { + TextProperty.Changed.AddClassHandler((h, _) => h._label.Text = h.Text ?? string.Empty); + HorizontalTextAlignmentProperty.Changed.AddClassHandler((h, _) => h._label.TextAlignment = h.HorizontalTextAlignment); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public TextAlignment HorizontalTextAlignment + { + get => GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + public TableHead() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + Height = 44; + Padding = new Thickness(16, 0); + Child = _label; + } +} + +// Body cell — p-4 text-sm. Set Text, or put any control inside. +public class TableCell : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty HorizontalTextAlignmentProperty = + AvaloniaProperty.Register(nameof(HorizontalTextAlignment), TextAlignment.Start); + + public static readonly StyledProperty IsBoldProperty = + AvaloniaProperty.Register(nameof(IsBold)); + + private readonly TextBlock _label; + + static TableCell() + { + TextProperty.Changed.AddClassHandler((c, _) => c._label.Text = c.Text ?? string.Empty); + HorizontalTextAlignmentProperty.Changed.AddClassHandler((c, _) => c._label.TextAlignment = c.HorizontalTextAlignment); + IsBoldProperty.Changed.AddClassHandler((c, _) => c._label.FontWeight = c.IsBold ? FontWeight.Medium : FontWeight.Normal); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public TextAlignment HorizontalTextAlignment + { + get => GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + public bool IsBold + { + get => GetValue(IsBoldProperty); + set => SetValue(IsBoldProperty, value); + } + + public TableCell() + { + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + Padding = new Thickness(16, 12); + Child = _label; + } +} diff --git a/examples/Avalonia.Demo/Components/UI/Toggle.cs b/examples/Avalonia.Demo/Components/UI/Toggle.cs new file mode 100644 index 0000000..4f30b73 --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/Toggle.cs @@ -0,0 +1,168 @@ +using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace AvaloniaDemo.Components.UI; + +// Two-state button — h-10 px-3 rounded-md text-sm font-medium; hover:bg-muted, and +// bg-accent text-accent-foreground while pressed. Variant Outline adds border-input. +// Usage: +public class Toggle : Border, IShellFocusable +{ + public static readonly StyledProperty IsPressedProperty = + AvaloniaProperty.Register(nameof(IsPressed), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), IconName.None); + + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size), ToggleSize.Default); + + private readonly Icon _icon; + private readonly TextBlock _label; + + static Toggle() + { + IsPressedProperty.Changed.AddClassHandler((t, _) => + { + t.UpdateVisualState(); + t.PressedChanged?.Invoke(t, t.IsPressed); + }); + TextProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + IconProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + VariantProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + SizeProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + } + + public bool IsPressed + { + get => GetValue(IsPressedProperty); + set => SetValue(IsPressedProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public ToggleVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + public ToggleSize Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + public event EventHandler? PressedChanged; + + public Toggle() + { + _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 } + }; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + HorizontalAlignment = HorizontalAlignment.Left; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + UpdateVisualState(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + UpdateVisualState(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + UpdateVisualState(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + IsPressed = !IsPressed; + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return; + IsPressed = !IsPressed; + e.Handled = true; + } + + private void UpdateVisualState() + { + var (height, padding) = Size switch + { + ToggleSize.Sm => (36.0, 10.0), + ToggleSize.Lg => (44.0, 20.0), + _ => (40.0, 12.0) + }; + Height = height; + MinWidth = height; + Padding = new Thickness(padding, 0); + BorderThickness = new Thickness(Variant == ToggleVariant.Outline ? 1 : 0); + this.Token(BorderBrushProperty, ShellToken.Input); + + _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); + + var hovered = IsPointerOver && IsEffectivelyEnabled; + var foreground = IsPressed ? ShellToken.AccentForeground + : hovered ? ShellToken.MutedForeground + : ShellToken.Foreground; + _label.Token(TextBlock.ForegroundProperty, foreground); + _icon.Token = foreground; + + if (IsPressed) 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 ToggleVariant { Default, Outline } +public enum ToggleSize { Sm, Default, Lg } diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 198c610..2dbe813 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -149,7 +149,7 @@ - + @@ -212,7 +212,7 @@ - + @@ -240,9 +240,9 @@ - - - + + + @@ -291,14 +291,142 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 34c5f3d..0013f69 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -36,6 +36,20 @@ public MainWindow() if (confirmed) Toast.Success("Account deleted", "This was only a demo."); }; + foreach (var row in DemoTable.Rows.OfType().Where(r => r is not TableHeader)) + row.Tapped += (_, _) => + { + foreach (var other in DemoTable.Rows.OfType()) other.IsSelected = other == row; + DataChoice.Text = $"Selected {((TableCell)row.Children.OfType().First()).Text}."; + }; + DemoPagination.PageChanged += (_, page) => DataChoice.Text = $"Page {page} of 20."; + + foreach (var item in DemoContextMenu.FindDescendantsOfType()) + item.Clicked += (_, _) => AdvancedChoice.Text = $"Context menu: {item.Text}."; + DemoCarousel.PositionChanged += (_, position) => AdvancedChoice.Text = $"Slide {position + 1} of 4."; + DemoStepper.CurrentStepChanged += (_, step) => AdvancedChoice.Text = $"Step {step + 1} of 3."; + DemoStepper.Confirmed += (_, _) => Toast.Success("Workspace created"); + for (var i = 1; i <= 30; i++) { var tag = new TextBlock { Text = $"v1.2.0-beta.{31 - i}", FontSize = 14 }; diff --git a/examples/MAUI.Demo/MAUI.Demo.csproj b/examples/MAUI.Demo/MAUI.Demo.csproj index 27bd530..4f93298 100644 --- a/examples/MAUI.Demo/MAUI.Demo.csproj +++ b/examples/MAUI.Demo/MAUI.Demo.csproj @@ -56,7 +56,6 @@ - diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index f15b7d6..3aa413c 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -309,12 +309,12 @@
((t, _) => t.Refresh()); + CaptionProperty.Changed.AddClassHandler
((t, _) => t.UpdateChrome()); + BorderedProperty.Changed.AddClassHandler
((t, _) => t.UpdateChrome()); + MinimumContentWidthProperty.Changed.AddClassHandler
((t, _) => t.UpdateScrolling()); + } + + public string? Columns + { + get => GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + + public string? Caption + { + get => GetValue(CaptionProperty); + set => SetValue(CaptionProperty, value); + } + + public bool Bordered + { + get => GetValue(BorderedProperty); + set => SetValue(BorderedProperty, value); + } + + public double MinimumContentWidth + { + get => GetValue(MinimumContentWidthProperty); + set => SetValue(MinimumContentWidthProperty, value); + } + + [Content] + public Controls Rows => _rows.Children; + + public Table() + { + _rows.Children.CollectionChanged += (_, _) => Refresh(); + _scroll = new ScrollViewer + { + Content = _rows, + HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled, + VerticalScrollBarVisibility = ScrollBarVisibility.Disabled + }; + _scroll.SizeChanged += (_, _) => FitScrollContent(); + + _frame = new Border + { + Child = _scroll, + CornerRadius = new CornerRadius(ShellTheme.RadiusMd), + // Row tints stay inside the rounded corners. + ClipToBounds = true + }; + _frame.Token(BorderBrushProperty, ShellToken.Border); + + _caption = new TextBlock { FontSize = 14, TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) }; + _caption.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + + Child = new StackPanel { Children = { _frame, _caption } }; + UpdateChrome(); + } + + // Parses ""2*,*,100"" into column widths. Empty means equal star columns. + internal static IReadOnlyList ParseColumns(string? columns) + { + if (string.IsNullOrWhiteSpace(columns)) return Array.Empty(); + return columns.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(ParseWidth) + .ToList(); + + static GridLength ParseWidth(string part) + { + if (part.Equals(""auto"", StringComparison.OrdinalIgnoreCase)) return GridLength.Auto; + if (part.EndsWith('*')) + { + var factor = part[..^1]; + return new GridLength(factor.Length > 0 && double.TryParse(factor, NumberStyles.Float, CultureInfo.InvariantCulture, out var stars) ? stars : 1, GridUnitType.Star); + } + return double.TryParse(part, NumberStyles.Float, CultureInfo.InvariantCulture, out var width) ? new GridLength(width) : GridLength.Star; + } + } + + // The table hands rows their columns and tells the last one to drop its divider. + private void Refresh() + { + var widths = ParseColumns(Columns); + var rows = _rows.Children.OfType().ToList(); + for (var i = 0; i < rows.Count; i++) + { + if (string.IsNullOrWhiteSpace(rows[i].Columns)) rows[i].Apply(widths); + rows[i].ShowDivider = i < rows.Count - 1; + } + } + + private void UpdateChrome() + { + _frame.BorderThickness = new Thickness(Bordered ? 1 : 0); + _caption.Text = Caption ?? string.Empty; + _caption.IsVisible = !string.IsNullOrEmpty(Caption); + } + + private void UpdateScrolling() + { + _scroll.HorizontalScrollBarVisibility = MinimumContentWidth > 0 ? ScrollBarVisibility.Auto : ScrollBarVisibility.Disabled; + FitScrollContent(); + } + + // A sideways ScrollViewer measures rows at their smallest width; give them a real one. + private void FitScrollContent() + { + _rows.Width = MinimumContentWidth > 0 && _scroll.Bounds.Width > 0 + ? Math.Max(_scroll.Bounds.Width, MinimumContentWidth) + : double.NaN; + } +} + +// Body row — border-b, hover:bg-muted/50, bg-muted when selected. Its children are the cells, +// placed in columns in order. Handle clicks and taps with Avalonia's own Tapped event. +public class TableRow : Grid +{ + // Set only for rows used outside a Table (e.g. an ItemsControl item template). + public static readonly StyledProperty ColumnsProperty = + AvaloniaProperty.Register(nameof(Columns)); + + public static readonly StyledProperty IsSelectedProperty = + AvaloniaProperty.Register(nameof(IsSelected)); + + private readonly Border _fill = new() { Opacity = 0, IsHitTestVisible = false, ZIndex = -1 }; + private readonly Border _divider = new() { Height = 1, VerticalAlignment = VerticalAlignment.Bottom, IsHitTestVisible = false }; + private IReadOnlyList _widths = Array.Empty(); + + static TableRow() + { + ColumnsProperty.Changed.AddClassHandler((r, _) => r.Apply(Table.ParseColumns(r.Columns))); + IsSelectedProperty.Changed.AddClassHandler((r, _) => r.UpdateFill()); + } + + public string? Columns + { + get => GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + + public bool IsSelected + { + get => GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + internal bool ShowDivider + { + get => _divider.IsVisible; + set => _divider.IsVisible = value; + } + + protected virtual bool Interactive => true; + + public TableRow() + { + _fill.Token(Border.BackgroundProperty, ShellToken.Muted); + _divider.Token(Border.BackgroundProperty, ShellToken.Border); + Children.Add(_fill); + Children.Add(_divider); + // A panel with no background only takes pointer input over its children. + Background = Brushes.Transparent; + Children.CollectionChanged += (_, _) => Apply(_widths); + if (Interactive) Cursor = new Cursor(StandardCursorType.Hand); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + UpdateFill(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + UpdateFill(); + } + + internal void Apply(IReadOnlyList widths) + { + _widths = widths; + var cells = Children.Where(c => c != _fill && c != _divider).ToList(); + var count = Math.Max(1, Math.Max(cells.Count, widths.Count)); + + ColumnDefinitions.Clear(); + for (var i = 0; i < count; i++) + ColumnDefinitions.Add(new ColumnDefinition(i < widths.Count ? widths[i] : GridLength.Star)); + for (var i = 0; i < cells.Count; i++) + SetColumn(cells[i], i); + SetColumnSpan(_fill, count); + SetColumnSpan(_divider, count); + } + + private void UpdateFill() => _fill.Opacity = IsSelected ? 1 : Interactive && IsPointerOver ? 0.5 : 0; +} + +// Header row: holds TableHead cells, no hover. +public class TableHeader : TableRow +{ + protected override bool Interactive => false; +} + +// Header cell — h-11 px-4 font-medium text-muted-foreground. +public class TableHead : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty HorizontalTextAlignmentProperty = + AvaloniaProperty.Register(nameof(HorizontalTextAlignment), TextAlignment.Start); + + private readonly TextBlock _label; + + static TableHead() + { + TextProperty.Changed.AddClassHandler((h, _) => h._label.Text = h.Text ?? string.Empty); + HorizontalTextAlignmentProperty.Changed.AddClassHandler((h, _) => h._label.TextAlignment = h.HorizontalTextAlignment); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public TextAlignment HorizontalTextAlignment + { + get => GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + public TableHead() + { + _label = new TextBlock { FontSize = 14, FontWeight = FontWeight.Medium, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground); + Height = 44; + Padding = new Thickness(16, 0); + Child = _label; + } +} + +// Body cell — p-4 text-sm. Set Text, or put any control inside. +public class TableCell : Border +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty HorizontalTextAlignmentProperty = + AvaloniaProperty.Register(nameof(HorizontalTextAlignment), TextAlignment.Start); + + public static readonly StyledProperty IsBoldProperty = + AvaloniaProperty.Register(nameof(IsBold)); + + private readonly TextBlock _label; + + static TableCell() + { + TextProperty.Changed.AddClassHandler((c, _) => c._label.Text = c.Text ?? string.Empty); + HorizontalTextAlignmentProperty.Changed.AddClassHandler((c, _) => c._label.TextAlignment = c.HorizontalTextAlignment); + IsBoldProperty.Changed.AddClassHandler((c, _) => c._label.FontWeight = c.IsBold ? FontWeight.Medium : FontWeight.Normal); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public TextAlignment HorizontalTextAlignment + { + get => GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + public bool IsBold + { + get => GetValue(IsBoldProperty); + set => SetValue(IsBoldProperty, value); + } + + public TableCell() + { + _label = new TextBlock { FontSize = 14, VerticalAlignment = VerticalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + _label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground); + Padding = new Thickness(16, 12); + Child = _label; + } +} " }; } diff --git a/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs b/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs index 435a1ed..fe4096b 100644 --- a/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs +++ b/src/ShellUI.Native.Templates/Templates/ToggleTemplate.cs @@ -175,6 +175,175 @@ private void UpdateVisualState() } } +public enum ToggleVariant { Default, Outline } +public enum ToggleSize { Sm, Default, Lg } +", + [NativePlatform.Avalonia] = @"using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Data; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace YourProjectNamespace.Components.UI; + +// Two-state button — h-10 px-3 rounded-md text-sm font-medium; hover:bg-muted, and +// bg-accent text-accent-foreground while pressed. Variant Outline adds border-input. +// Usage: +public class Toggle : Border, IShellFocusable +{ + public static readonly StyledProperty IsPressedProperty = + AvaloniaProperty.Register(nameof(IsPressed), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon), IconName.None); + + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + + public static readonly StyledProperty SizeProperty = + AvaloniaProperty.Register(nameof(Size), ToggleSize.Default); + + private readonly Icon _icon; + private readonly TextBlock _label; + + static Toggle() + { + IsPressedProperty.Changed.AddClassHandler((t, _) => + { + t.UpdateVisualState(); + t.PressedChanged?.Invoke(t, t.IsPressed); + }); + TextProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + IconProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + VariantProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + SizeProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + IsEnabledProperty.Changed.AddClassHandler((t, _) => t.UpdateVisualState()); + } + + public bool IsPressed + { + get => GetValue(IsPressedProperty); + set => SetValue(IsPressedProperty, value); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public ToggleVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + public ToggleSize Size + { + get => GetValue(SizeProperty); + set => SetValue(SizeProperty, value); + } + + public event EventHandler? PressedChanged; + + public Toggle() + { + _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 } + }; + CornerRadius = new CornerRadius(ShellTheme.RadiusMd); + HorizontalAlignment = HorizontalAlignment.Left; + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + UpdateVisualState(); + } + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + UpdateVisualState(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + UpdateVisualState(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return; + IsPressed = !IsPressed; + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return; + IsPressed = !IsPressed; + e.Handled = true; + } + + private void UpdateVisualState() + { + var (height, padding) = Size switch + { + ToggleSize.Sm => (36.0, 10.0), + ToggleSize.Lg => (44.0, 20.0), + _ => (40.0, 12.0) + }; + Height = height; + MinWidth = height; + Padding = new Thickness(padding, 0); + BorderThickness = new Thickness(Variant == ToggleVariant.Outline ? 1 : 0); + this.Token(BorderBrushProperty, ShellToken.Input); + + _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); + + var hovered = IsPointerOver && IsEffectivelyEnabled; + var foreground = IsPressed ? ShellToken.AccentForeground + : hovered ? ShellToken.MutedForeground + : ShellToken.Foreground; + _label.Token(TextBlock.ForegroundProperty, foreground); + _icon.Token = foreground; + + if (IsPressed) 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 ToggleVariant { Default, Outline } public enum ToggleSize { Sm, Default, Lg } " diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs index 12674ea..3378a42 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 P4. Grows as components are ported. + // Ported so far: the Phase 2 foundation, P0 to P5 and three of P6. Grows as components are ported. [Theory] [InlineData("shell")] [InlineData("icon")] @@ -91,6 +91,15 @@ public static IEnumerable AvaloniaComponents() [InlineData("hover-card")] [InlineData("hover-card-trigger")] [InlineData("hover-card-content")] + [InlineData("avatar")] + [InlineData("toggle")] + [InlineData("callout")] + [InlineData("table")] + [InlineData("empty-state")] + [InlineData("pagination")] + [InlineData("context-menu")] + [InlineData("carousel")] + [InlineData("stepper")] public void Ported_components_have_Avalonia_content(string name) { Assert.False(string.IsNullOrWhiteSpace(ComponentRegistry.GetComponentContent(name, NativePlatform.Avalonia)));