diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 563c5ce..ced7ff7 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -144,6 +144,7 @@ Same names, properties and variants as MAUI, except: | Menubar | Tap a title to open its menu | Titles are tab stops: Enter / Space / Down open a menu and Left / Right move between titles; in a menu Up / Down move and Enter picks; children are collected in `Menus` and `Items` | | Resizable | Drag a divider | Dividers are also tab stops: the arrow keys move them 16px; the pointer shows a resize cursor | | Navbar | Links and the menu button react to taps | They are also tab stops (Enter / Space); children are collected in `Items` | +| EdgeMenu | Handle and tiles react to taps (and Enter / Space on Windows); back closes it on Android | Handle and tiles are tab stops: Enter / Space toggle or pick, Up / Down move between tiles; focus goes to the first tile on open and back to the handle on close | | BottomNav | Items react to taps (and Enter / Space on Windows); an item's XAML content is its `IconContent` | Items are also tab stops: Enter / Space pick one and Left / Right move to the next destination; set `IconContent` as a property element (``) | | Sidebar | Width animates on collapse | Same, with items as tab stops; children are collected in `Groups` and `Items` | | DateRangePicker | Tap the trigger | Also Enter / Space / Down on the focused trigger; Escape closes the panel | @@ -1033,6 +1034,40 @@ it raises `Clicked` and never becomes the selected item), `IsEnabled` · **Event --- +### EdgeMenu +A menu docked to a screen edge: a small handle that slides out a rail of icon tiles, for tools and +shortcuts that shouldn't take a tab in the bottom nav. Put it over the page, in the same cell +after the content; only the handle and the open rail take input, so the page stays usable while +it is closed. A tap outside, Escape (back on Android), the handle or a tile closes it. + +```bash +shellui-native add edge-menu +``` + +**Variants:** `Default` (bg-popover, border, shadow; tiles turn bg-accent on hover and when +selected), `Solid` (bg-primary with primary-foreground tiles, like a dark dock). + +**Properties (EdgeMenu):** `IsOpen` (two-way), `Side` (`Right`, `Left`), `Position` (where the +handle sits along the edge: 0 top, 0.5 middle, 1 bottom), `Variant`, `Dim` (darkens the page while +open), `CloseOnSelect` (true), `SelectedValue` (two-way; optional, tiles can be plain actions) · +**Events:** `IsOpenChanged` (every open and close, like the other overlays), `SelectedValueChanged` +(user input only) · **Methods:** `Open()`, `Close()`, `Toggle()` + +**Properties (EdgeMenuItem):** `Icon`, `Text`, `Value`, `IsEnabled` · **Events:** `Clicked` · +**Methods:** `Press()` + +```xml + + ... + + + + + +``` + +--- + ### Sidebar App sidebar: a 256px column with a header, scrolling groups of items and a footer. Collapsed, it is a 56px rail that shows the icons only. Put it in a column next to your content (or diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index 5c96af3..8003c19 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -220,6 +220,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc | aspect-ratio ✅ | — | Done 2026-10-05 | | wrap-layout ✅ | — | Done 2026-10-05 — wrapping layout used by tag-input and the demo's button rows | | theme-toggle ✅ | P7 | Done — `ThemeToggle` component | +| **edge-menu** ✅ | — | Done 2026-10-16, MAUI and Avalonia — native only: a handle on a screen edge slides out a rail of tool tiles; Default / Solid, left or right, optional dimming | | **bottom-nav** ✅ | — | Done 2026-10-16, MAUI and Avalonia — native only (ShellUI for Blazor has none): Default, Pill, Floating, Lifted and Expanding styles, badges and dots, a raised action button, avatar items, hide on scroll, the safe area under the bar | --- diff --git a/examples/Avalonia.Demo/Components/UI/EdgeMenu.cs b/examples/Avalonia.Demo/Components/UI/EdgeMenu.cs new file mode 100644 index 0000000..c8dccce --- /dev/null +++ b/examples/Avalonia.Demo/Components/UI/EdgeMenu.cs @@ -0,0 +1,478 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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; + +namespace AvaloniaDemo.Components.UI; + +// A menu docked to a screen edge: a small handle that slides out a rail of icon tiles — tools and +// shortcuts that shouldn't take a tab in the bottom nav. Put it over the page (same cell, after +// the content); only the handle and the open rail take input. A tap outside, Escape, the handle +// or a tile closes it. +// +// ... +// +// +// +// +// +// Default: rounded-l-2xl border bg-popover shadow-lg, tiles hover:bg-accent. +// Solid: bg-primary text-primary-foreground, like a dark dock. +public class EdgeMenu : Border +{ + public static readonly StyledProperty IsOpenProperty = + AvaloniaProperty.Register(nameof(IsOpen), defaultBindingMode: BindingMode.TwoWay); + + public static readonly StyledProperty SideProperty = + AvaloniaProperty.Register(nameof(Side), EdgeMenuSide.Right); + + // Where the handle sits along the edge: 0 top, 0.5 middle, 1 bottom. + public static readonly StyledProperty PositionProperty = + AvaloniaProperty.Register(nameof(Position), 0.5); + + public static readonly StyledProperty VariantProperty = + AvaloniaProperty.Register(nameof(Variant)); + + // Dims the page while the menu is open. + public static readonly StyledProperty DimProperty = + AvaloniaProperty.Register(nameof(Dim)); + + // Picking a tile closes the menu. + public static readonly StyledProperty CloseOnSelectProperty = + AvaloniaProperty.Register(nameof(CloseOnSelect), true); + + // The highlighted tile's Value (optional: tiles can be plain actions). + public static readonly StyledProperty SelectedValueProperty = + AvaloniaProperty.Register(nameof(SelectedValue), defaultBindingMode: BindingMode.TwoWay); + + private const int Duration = 220; + private static readonly Easing Ease = new CubicEaseOut(); + + private readonly Border _backdrop; + private readonly StackPanel _dock; + private readonly Border _handle; + private readonly Icon _chevron; + private readonly Border _panel; + private readonly StackPanel _list; + private bool _byUser; + + static EdgeMenu() + { + IsOpenProperty.Changed.AddClassHandler((m, _) => m.OnOpenChanged()); + SideProperty.Changed.AddClassHandler((m, _) => m.ApplyLook()); + VariantProperty.Changed.AddClassHandler((m, _) => m.ApplyLook()); + PositionProperty.Changed.AddClassHandler((m, _) => m.PlaceDock()); + DimProperty.Changed.AddClassHandler((m, _) => m.Slide(animate: false)); + SelectedValueProperty.Changed.AddClassHandler((m, _) => + { + m.Refresh(); + if (m._byUser) m.SelectedValueChanged?.Invoke(m, m.SelectedValue); + }); + } + + public bool IsOpen + { + get => GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + public EdgeMenuSide Side + { + get => GetValue(SideProperty); + set => SetValue(SideProperty, value); + } + + public double Position + { + get => GetValue(PositionProperty); + set => SetValue(PositionProperty, value); + } + + public EdgeMenuVariant Variant + { + get => GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + public bool Dim + { + get => GetValue(DimProperty); + set => SetValue(DimProperty, value); + } + + public bool CloseOnSelect + { + get => GetValue(CloseOnSelectProperty); + set => SetValue(CloseOnSelectProperty, value); + } + + public string? SelectedValue + { + get => GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + // The EdgeMenuItem children. + [Content] + public Controls Items { get; } = new(); + + // Raised on every open and close, like the other overlays. + public event EventHandler? IsOpenChanged; + + // User input only: setting SelectedValue from code or a binding doesn't raise it. + public event EventHandler? SelectedValueChanged; + + public EdgeMenu() + { + // Covers the page while open, so a tap outside closes the menu instead of reaching the page. + _backdrop = new Border { IsVisible = false, Opacity = 0 }; + _backdrop.PointerPressed += (_, e) => + { + IsOpen = false; + e.Handled = true; + }; + + _chevron = new Icon { Size = 16 }; + _handle = new Border + { + Child = _chevron, + Width = 28, + Height = 64, + VerticalAlignment = VerticalAlignment.Center, + Cursor = new Cursor(StandardCursorType.Hand), + Focusable = true + }; + ShellFocus.Ring(_handle, _handle); + _handle.PointerReleased += (_, e) => + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + IsOpen = !IsOpen; + e.Handled = true; + }; + _handle.KeyDown += (_, e) => + { + if (e.Key is not (Key.Enter or Key.Space)) return; + IsOpen = !IsOpen; + e.Handled = true; + }; + + _list = new StackPanel { Spacing = 6 }; + _panel = new Border + { + Child = new ScrollViewer { Content = _list, VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Hidden }, + Padding = new Thickness(8), + BorderThickness = new Thickness(1) + }; + _dock = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Top }; + + Child = new Panel { Children = { _backdrop, _dock } }; + // Only the handle and the open rail take input: the rest of this control is see-through. + Background = null; + ClipToBounds = true; + Items.CollectionChanged += (_, _) => Rebuild(); + SizeChanged += (_, _) => PlaceDock(); + _dock.SizeChanged += (_, _) => PlaceDock(); + _panel.SizeChanged += (_, _) => Slide(animate: false); + ApplyLook(); + } + + private List ItemList => Items.OfType().ToList(); + + public void Open() => IsOpen = true; + public void Close() => IsOpen = false; + public void Toggle() => IsOpen = !IsOpen; + + // A tap or Enter / Space on a tile. + internal void Pick(EdgeMenuItem item) + { + if (!string.IsNullOrEmpty(item.Value)) + { + var outer = _byUser; + _byUser = true; + try { SelectedValue = item.Value; } + finally { _byUser = outer; } + } + if (CloseOnSelect) IsOpen = false; + } + + // Up and Down move between tiles. + internal void Step(EdgeMenuItem from, int direction) + { + var items = ItemList.Where(i => i.IsEffectivelyEnabled).ToList(); + var index = items.IndexOf(from) + direction; + if (index >= 0 && index < items.Count) items[index].Focus(NavigationMethod.Directional); + } + + private void Rebuild() + { + _list.Children.Clear(); + foreach (var item in ItemList) _list.Children.Add(item); + Refresh(); + } + + private void Refresh() + { + foreach (var item in ItemList) + item.Apply(!string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, Variant); + } + + private void ApplyLook() + { + var right = Side == EdgeMenuSide.Right; + var solid = Variant == EdgeMenuVariant.Solid; + _dock.Children.Clear(); + if (right) _dock.Children.AddRange(new Control[] { _handle, _panel }); + else _dock.Children.AddRange(new Control[] { _panel, _handle }); + _dock.HorizontalAlignment = right ? HorizontalAlignment.Right : HorizontalAlignment.Left; + + // Rounded on the inner side only; the outer side runs into the screen edge. + _handle.CornerRadius = right ? new CornerRadius(12, 0, 0, 12) : new CornerRadius(0, 12, 12, 0); + _panel.CornerRadius = right ? new CornerRadius(16, 0, 0, 16) : new CornerRadius(0, 16, 16, 0); + _panel.BorderThickness = solid ? default : right ? new Thickness(1, 1, 0, 1) : new Thickness(0, 1, 1, 1); + _panel.BoxShadow = new BoxShadows(new BoxShadow { OffsetY = 10, Blur = 24, Color = ShellTheme.Shadow(0.15) }); + + _handle.Token(BackgroundProperty, solid ? ShellToken.Primary : ShellToken.Popover); + _handle.BorderThickness = solid ? default : right ? new Thickness(1, 1, 0, 1) : new Thickness(0, 1, 1, 1); + _handle.Token(BorderBrushProperty, ShellToken.Border); + _chevron.Token = solid ? ShellToken.PrimaryForeground : ShellToken.PopoverForeground; + _panel.Token(BackgroundProperty, solid ? ShellToken.Primary : ShellToken.Popover); + _panel.Token(BorderBrushProperty, ShellToken.Border); + Refresh(); + PlaceDock(); + Slide(animate: false); + } + + // The dock's top follows Position, kept inside this control. + private void PlaceDock() + { + var room = Bounds.Height - _dock.DesiredSize.Height; + _dock.Margin = new Thickness(0, Math.Max(0, room * Math.Clamp(Position, 0, 1)), 0, 0); + _panel.MaxHeight = Math.Max(0, Bounds.Height - 32); + } + + private void OnOpenChanged() + { + if (IsOpen) ShellDismiss.Push(this, Close, this); + else ShellDismiss.Remove(this); + Slide(animate: IsLoaded); + // Focus goes to the first tile on open and back to the handle on close. + if (IsOpen) ItemList.FirstOrDefault(i => i.IsEffectivelyEnabled)?.Focus(); + else if (IsKeyboardFocusWithin) _handle.Focus(); + IsOpenChanged?.Invoke(this, IsOpen); + } + + // Closed, the dock is pushed out by the panel's width so only the handle shows. + private void Slide(bool animate) + { + var width = _panel.Bounds.Width > 0 ? _panel.Bounds.Width : 90; + var shift = IsOpen ? 0 : Side == EdgeMenuSide.Right ? width : -width; + var transform = FormattableString.Invariant($"translateX({shift}px)"); + if (animate) _ = ShellMotion.To(_dock, 1, transform, Duration, Ease); + else ShellMotion.Set(_dock, 1, transform); + // The panel fades too, so its shadow doesn't show at the edge while closed. + if (animate) _ = ShellMotion.To(_panel, IsOpen ? 1 : 0, "scale(1)", Duration, Ease); + else ShellMotion.Set(_panel, IsOpen ? 1 : 0, "scale(1)"); + + _chevron.Kind = IsOpen == (Side == EdgeMenuSide.Right) ? IconName.ChevronRight : IconName.ChevronLeft; + AutomationProperties.SetName(_handle, IsOpen ? "Close menu" : "Open menu"); + _panel.IsHitTestVisible = IsOpen; + + _backdrop.IsVisible = IsOpen; + if (Dim) _backdrop.Background = new SolidColorBrush(ShellTheme.Shadow(0.4)); + else _backdrop.Background = Brushes.Transparent; + if (animate && IsOpen) _ = ShellMotion.To(_backdrop, 1, "scale(1)", Duration, Ease); + else ShellMotion.Set(_backdrop, IsOpen ? 1 : 0, "scale(1)"); + } +} + +// A tile in an EdgeMenu — an icon over a short label (rounded-xl, 72 x 60). A tab stop: Enter or +// Space picks it, Up and Down move between tiles. +public class EdgeMenuItem : Border, IShellFocusable +{ + public static readonly StyledProperty TextProperty = + AvaloniaProperty.Register(nameof(Text)); + + // Identifies the tile for EdgeMenu.SelectedValue (optional). + public static readonly StyledProperty ValueProperty = + AvaloniaProperty.Register(nameof(Value)); + + public static readonly StyledProperty IconProperty = + AvaloniaProperty.Register(nameof(Icon)); + + private readonly Icon _icon; + private readonly TextBlock _label; + private bool _active; + private EdgeMenuVariant _variant; + + static EdgeMenuItem() + { + TextProperty.Changed.AddClassHandler((i, _) => i.Update()); + IconProperty.Changed.AddClassHandler((i, _) => i.Update()); + IsEnabledProperty.Changed.AddClassHandler((i, _) => i.Update()); + } + + public string? Text + { + get => GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public bool IsActive => _active; + + // Raised on every tap or Enter / Space. + public event EventHandler? Clicked; + + public EdgeMenuItem() + { + _icon = new Icon { Size = 20 }; + _label = new TextBlock { FontSize = 11, HorizontalAlignment = HorizontalAlignment.Center, TextTrimming = TextTrimming.CharacterEllipsis }; + Child = new StackPanel + { + Spacing = 4, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Children = { _icon, _label } + }; + Width = 72; + Height = 60; + Padding = new Thickness(4, 0); + CornerRadius = new CornerRadius(12); + Cursor = new Cursor(StandardCursorType.Hand); + ShellFocus.Ring(this, this); + Update(); + } + + // Solid tiles mix their color from the theme, so they follow it by hand. + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + ShellTheme.ThemeChanged += OnThemeChanged; + Update(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + ShellTheme.ThemeChanged -= OnThemeChanged; + } + + private void OnThemeChanged(object? sender, EventArgs e) => Update(); + + protected override void OnPointerEntered(PointerEventArgs e) + { + base.OnPointerEntered(e); + Update(); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + Update(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (IsEffectivelyEnabled) _ = ShellMotion.To(this, Opacity, "scale(0.95)", 80, new CubicEaseOut()); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + _ = ShellMotion.To(this, Opacity, "scale(1)", 150, new CubicEaseOut()); + if (e.InitialPressMouseButton != MouseButton.Left) return; + Press(); + e.Handled = true; + } + + protected override void OnKeyDown(KeyEventArgs e) + { + base.OnKeyDown(e); + switch (e.Key) + { + case Key.Enter or Key.Space: + Press(); + break; + case Key.Up or Key.Down: + this.FindParentOfType()?.Step(this, e.Key == Key.Down ? 1 : -1); + break; + default: + return; + } + e.Handled = true; + } + + // Picks the tile in code, as a tap or Enter / Space would. + public void Press() + { + if (!IsEffectivelyEnabled) return; + Clicked?.Invoke(this, EventArgs.Empty); + this.FindParentOfType()?.Pick(this); + } + + internal void Apply(bool isActive, EdgeMenuVariant variant) + { + _active = isActive; + _variant = variant; + Update(); + } + + private void Update() + { + _icon.Kind = Icon; + _label.Text = Text ?? string.Empty; + _label.IsVisible = !string.IsNullOrEmpty(Text); + AutomationProperties.SetName(this, Text); + + var hovered = IsPointerOver && IsEffectivelyEnabled; + if (_variant == EdgeMenuVariant.Solid) + { + // Tiles a shade lighter than the dock; brighter when hovered or active. + var shade = _active ? 0.2 : hovered ? 0.14 : 0.08; + this.ClearToken(BackgroundProperty); + Background = new SolidColorBrush(ShellTheme.Get(ShellToken.PrimaryForeground, shade)); + _icon.Token = ShellToken.PrimaryForeground; + _label.Token(TextBlock.ForegroundProperty, ShellToken.PrimaryForeground); + _label.Opacity = _active || hovered ? 1 : 0.75; + } + else + { + if (_active || hovered) this.Token(BackgroundProperty, ShellToken.Accent); + else + { + this.ClearToken(BackgroundProperty); + Background = Brushes.Transparent; + } + var foreground = _active || hovered ? ShellToken.AccentForeground : ShellToken.MutedForeground; + _icon.Token = _active || hovered ? ShellToken.AccentForeground : ShellToken.PopoverForeground; + _label.Token(TextBlock.ForegroundProperty, foreground); + _label.Opacity = 1; + } + _label.FontWeight = _active ? FontWeight.SemiBold : FontWeight.Normal; + Opacity = IsEnabled ? 1.0 : 0.5; + } +} + +public enum EdgeMenuSide { Right, Left } + +public enum EdgeMenuVariant { Default, Solid } diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml index 2e08092..2d08f32 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml +++ b/examples/Avalonia.Demo/MainWindow.axaml @@ -684,6 +684,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs index 2a20477..31f27c5 100644 --- a/examples/Avalonia.Demo/MainWindow.axaml.cs +++ b/examples/Avalonia.Demo/MainWindow.axaml.cs @@ -154,6 +154,15 @@ void ShowDocsPage(int page) BottomNavChoice.Text = $"{nav.Variant}: {value}."; }; BottomNavPay.Clicked += (_, _) => BottomNavChoice.Text = "Pay pressed (an action, not a tab)."; + foreach (var (menu, page) in new[] { (EdgeMenuRight, EdgeMenuRightPage), (EdgeMenuLeft, EdgeMenuLeftPage) }) + { + menu.SelectedValueChanged += (_, value) => + { + page.Text = string.IsNullOrEmpty(value) ? string.Empty : char.ToUpperInvariant(value[0]) + value.Substring(1); + EdgeMenuChoice.Text = $"Edge menu ({menu.Side}): {value}."; + }; + menu.IsOpenChanged += (_, open) => EdgeMenuChoice.Text = $"Edge menu ({menu.Side}) {(open ? "opened" : "closed")}."; + } DemoResizable.Resized += (_, _) => LayoutChoice.Text = $"Resizable: first panel is {ResizableFirst.Size / DemoResizable.Panels.OfType().Sum(p => p.Size):P0} of the width."; diff --git a/examples/MAUI.Demo/Components/UI/EdgeMenu.cs b/examples/MAUI.Demo/Components/UI/EdgeMenu.cs new file mode 100644 index 0000000..16c1e3f --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/EdgeMenu.cs @@ -0,0 +1,408 @@ +using System.Collections.ObjectModel; +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// A menu docked to a screen edge: a small handle that slides out a rail of icon tiles — tools and +// shortcuts that shouldn't take a tab in the bottom nav. Put it over the page (same cell, after +// the content); only the handle and the open rail take input. A tap outside, Escape / back, the +// handle or a tile closes it. +// +// ... +// +// +// +// +// +// Default: rounded-l-2xl border bg-popover shadow-lg, tiles hover:bg-accent. +// Solid: bg-primary text-primary-foreground, like a dark dock. +[ContentProperty(nameof(Items))] +public partial class EdgeMenu : ContentView +{ + public static readonly BindableProperty IsOpenProperty = + BindableProperty.Create(nameof(IsOpen), typeof(bool), typeof(EdgeMenu), false, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((EdgeMenu)b).OnOpenChanged()); + + public static readonly BindableProperty SideProperty = + BindableProperty.Create(nameof(Side), typeof(EdgeMenuSide), typeof(EdgeMenu), EdgeMenuSide.Right, + propertyChanged: (b, o, n) => ((EdgeMenu)b).ApplyLook()); + + // Where the handle sits along the edge: 0 top, 0.5 middle, 1 bottom. + public static readonly BindableProperty PositionProperty = + BindableProperty.Create(nameof(Position), typeof(double), typeof(EdgeMenu), 0.5, + propertyChanged: (b, o, n) => ((EdgeMenu)b).PlaceDock()); + + public static readonly BindableProperty VariantProperty = + BindableProperty.Create(nameof(Variant), typeof(EdgeMenuVariant), typeof(EdgeMenu), EdgeMenuVariant.Default, + propertyChanged: (b, o, n) => ((EdgeMenu)b).ApplyLook()); + + // Dims the page while the menu is open. + public static readonly BindableProperty DimProperty = + BindableProperty.Create(nameof(Dim), typeof(bool), typeof(EdgeMenu), false, + propertyChanged: (b, o, n) => ((EdgeMenu)b).Slide(animate: false)); + + // Picking a tile closes the menu. + public static readonly BindableProperty CloseOnSelectProperty = + BindableProperty.Create(nameof(CloseOnSelect), typeof(bool), typeof(EdgeMenu), true); + + // The highlighted tile's Value (optional: tiles can be plain actions). + public static readonly BindableProperty SelectedValueProperty = + BindableProperty.Create(nameof(SelectedValue), typeof(string), typeof(EdgeMenu), null, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((EdgeMenu)b).OnSelectedValueChanged()); + + public bool IsOpen + { + get => (bool)GetValue(IsOpenProperty); + set => SetValue(IsOpenProperty, value); + } + + public EdgeMenuSide Side + { + get => (EdgeMenuSide)GetValue(SideProperty); + set => SetValue(SideProperty, value); + } + + public double Position + { + get => (double)GetValue(PositionProperty); + set => SetValue(PositionProperty, value); + } + + public EdgeMenuVariant Variant + { + get => (EdgeMenuVariant)GetValue(VariantProperty); + set => SetValue(VariantProperty, value); + } + + public bool Dim + { + get => (bool)GetValue(DimProperty); + set => SetValue(DimProperty, value); + } + + public bool CloseOnSelect + { + get => (bool)GetValue(CloseOnSelectProperty); + set => SetValue(CloseOnSelectProperty, value); + } + + public string? SelectedValue + { + get => (string?)GetValue(SelectedValueProperty); + set => SetValue(SelectedValueProperty, value); + } + + public IList Items => _items; + + // Raised on every open and close, like the other overlays. + public event EventHandler? IsOpenChanged; + + // User input only: setting SelectedValue from code or a binding doesn't raise it. + public event EventHandler? SelectedValueChanged; + + private const uint Duration = 220; + + private readonly ObservableCollection _items = new(); + private readonly Grid _backdrop; + private readonly HorizontalStackLayout _dock; + private readonly Border _handle; + private readonly Icon _chevron; + private readonly Border _panel; + private readonly VerticalStackLayout _list; + private bool _byUser; + + public EdgeMenu() + { + // Covers the page while open, so a tap outside closes the menu instead of reaching the page. + _backdrop = new Grid { IsVisible = false, Opacity = 0, BackgroundColor = Colors.Transparent }; + var outside = new TapGestureRecognizer(); + outside.Tapped += (_, _) => IsOpen = false; + _backdrop.GestureRecognizers.Add(outside); + + _chevron = new Icon { Size = 16 }; + _handle = new Border + { + Content = _chevron, + WidthRequest = 28, + HeightRequest = 64, + StrokeThickness = 1, + VerticalOptions = LayoutOptions.Center + }; + var toggle = new TapGestureRecognizer(); + toggle.Tapped += (_, _) => { ShellFocus.FocusPressed(_handle); IsOpen = !IsOpen; }; + _handle.GestureRecognizers.Add(toggle); + ShellFocus.MakeFocusable(_handle, () => IsOpen = !IsOpen); + + _list = new VerticalStackLayout { Spacing = 6 }; + _panel = new Border + { + Content = new ScrollView { Content = _list, VerticalScrollBarVisibility = ScrollBarVisibility.Never }, + Padding = new Thickness(8), + StrokeThickness = 1, + Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 10), Radius = 24, Opacity = 0.15f } + }; + _dock = new HorizontalStackLayout { VerticalOptions = LayoutOptions.Start }; + + Content = new Grid { Children = { _backdrop, _dock } }; + // Only the handle and the open rail take input: the rest of this view lets taps through. + InputTransparent = true; + CascadeInputTransparent = false; + IsClippedToBounds = true; + _items.CollectionChanged += (_, _) => Rebuild(); + SizeChanged += (_, _) => PlaceDock(); + _dock.SizeChanged += (_, _) => PlaceDock(); + _panel.SizeChanged += (_, _) => Slide(animate: false); + ApplyLook(); + } + + public void Open() => IsOpen = true; + public void Close() => IsOpen = false; + public void Toggle() => IsOpen = !IsOpen; + + // A tap or Enter / Space on a tile. + internal void Pick(EdgeMenuItem item) + { + if (!string.IsNullOrEmpty(item.Value)) + { + var outer = _byUser; + _byUser = true; + try { SelectedValue = item.Value; } + finally { _byUser = outer; } + } + if (CloseOnSelect) IsOpen = false; + } + + private void OnSelectedValueChanged() + { + Refresh(); + if (_byUser) SelectedValueChanged?.Invoke(this, SelectedValue); + } + + private void Rebuild() + { + _list.Children.Clear(); + foreach (var item in _items) _list.Children.Add(item); + Refresh(); + } + + private void Refresh() + { + foreach (var item in _items) + item.Apply(!string.IsNullOrEmpty(item.Value) && item.Value == SelectedValue, Variant); + } + + private void ApplyLook() + { + var right = Side == EdgeMenuSide.Right; + var solid = Variant == EdgeMenuVariant.Solid; + _dock.Children.Clear(); + _dock.Children.Add(right ? _handle : _panel); + _dock.Children.Add(right ? _panel : _handle); + _dock.HorizontalOptions = right ? LayoutOptions.End : LayoutOptions.Start; + + // Rounded on the inner side only; the outer side runs into the screen edge. + _handle.StrokeShape = new RoundRectangle { CornerRadius = right ? new CornerRadius(12, 0, 12, 0) : new CornerRadius(0, 12, 0, 12) }; + _panel.StrokeShape = new RoundRectangle { CornerRadius = right ? new CornerRadius(16, 0, 16, 0) : new CornerRadius(0, 16, 0, 16) }; + _panel.StrokeThickness = solid ? 0 : 1; + _handle.StrokeThickness = solid ? 0 : 1; + + _handle.Token(VisualElement.BackgroundColorProperty, solid ? ShellToken.Primary : ShellToken.Popover); + _handle.Token(Border.StrokeProperty, ShellToken.Border); + _chevron.Token = solid ? ShellToken.PrimaryForeground : ShellToken.PopoverForeground; + _panel.Token(VisualElement.BackgroundColorProperty, solid ? ShellToken.Primary : ShellToken.Popover); + _panel.Token(Border.StrokeProperty, ShellToken.Border); + Refresh(); + PlaceDock(); + Slide(animate: false); + } + + // The dock's top follows Position, kept inside this view. + private void PlaceDock() + { + if (Height <= 0) return; + var room = Height - _dock.Height; + _dock.Margin = new Thickness(0, Math.Max(0, room * Math.Clamp(Position, 0, 1)), 0, 0); + _panel.MaximumHeightRequest = Math.Max(0, Height - 32); + } + + private void OnOpenChanged() + { + if (IsOpen) ShellDismiss.Push(this, Close); + else ShellDismiss.Remove(this); + Slide(animate: IsLoaded); + IsOpenChanged?.Invoke(this, IsOpen); + } + + // Closed, the dock is pushed out by the panel's width so only the handle shows. + private void Slide(bool animate) + { + var width = _panel.Width > 0 ? _panel.Width : 90; + var shift = IsOpen ? 0 : Side == EdgeMenuSide.Right ? width : -width; + if (animate) + { + _ = _dock.TranslateToAsync(shift, 0, Duration, Easing.CubicOut); + // The panel fades too, so its shadow doesn't show at the edge while closed. + _ = _panel.FadeToAsync(IsOpen ? 1 : 0, Duration, Easing.CubicOut); + } + else + { + _dock.TranslationX = shift; + _panel.Opacity = IsOpen ? 1 : 0; + } + _panel.InputTransparent = !IsOpen; + + _chevron.Name = IsOpen == (Side == EdgeMenuSide.Right) ? IconName.ChevronRight : IconName.ChevronLeft; + SemanticProperties.SetDescription(_handle, IsOpen ? "Close menu" : "Open menu"); + + _backdrop.IsVisible = IsOpen; + _backdrop.BackgroundColor = Dim ? Colors.Black.WithAlpha(0.4f) : Colors.Transparent; + if (animate && IsOpen) _ = _backdrop.FadeToAsync(1, Duration, Easing.CubicOut); + else _backdrop.Opacity = IsOpen ? 1 : 0; + } +} + +// A tile in an EdgeMenu — an icon over a short label (rounded-xl, 72 x 60). Tap or Enter / Space +// picks it. +public partial class EdgeMenuItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(EdgeMenuItem), null, + propertyChanged: (b, o, n) => ((EdgeMenuItem)b).Update()); + + // Identifies the tile for EdgeMenu.SelectedValue (optional). + public static readonly BindableProperty ValueProperty = + BindableProperty.Create(nameof(Value), typeof(string), typeof(EdgeMenuItem), null); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(EdgeMenuItem), IconName.None, + propertyChanged: (b, o, n) => ((EdgeMenuItem)b).Update()); + + public string? Text + { + get => (string?)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public string? Value + { + get => (string?)GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public bool IsActive => _active; + + // Raised on every tap or Enter / Space. + public event EventHandler? Clicked; + + private readonly Border _tile; + private readonly Icon _icon; + private readonly Label _label; + private bool _active; + private bool _hovered; + private EdgeMenuVariant _variant; + + public EdgeMenuItem() + { + _icon = new Icon { Size = 20 }; + _label = new Label { FontSize = 11, HorizontalOptions = LayoutOptions.Center, HorizontalTextAlignment = TextAlignment.Center, LineBreakMode = LineBreakMode.TailTruncation, MaxLines = 1 }; + _tile = new Border + { + Content = new VerticalStackLayout + { + Spacing = 4, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center, + Children = { _icon, _label } + }, + WidthRequest = 72, + HeightRequest = 60, + Padding = new Thickness(4, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = 12 } + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = true; Update(); }; + pointer.PointerExited += (_, _) => { _hovered = false; Update(); _ = _tile.ScaleToAsync(1, 150, Easing.CubicOut); }; + // A small press-in. + pointer.PointerPressed += (_, _) => { if (IsEnabled) _ = _tile.ScaleToAsync(0.95, 80, Easing.CubicOut); }; + pointer.PointerReleased += (_, _) => _ = _tile.ScaleToAsync(1, 150, Easing.CubicOut); + _tile.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); Press(); }; + _tile.GestureRecognizers.Add(tap); + ShellFocus.MakeFocusable(this, Press); + + Content = _tile; + // Solid tiles mix their color from the theme, so they follow it by hand. + Loaded += (_, _) => ShellTheme.ThemeChanged += OnThemeChanged; + Unloaded += (_, _) => ShellTheme.ThemeChanged -= OnThemeChanged; + Update(); + } + + private void OnThemeChanged(object? sender, EventArgs e) => Update(); + + // Picks the tile in code, as a tap or Enter / Space would. + public void Press() + { + if (!IsEnabled) return; + Clicked?.Invoke(this, EventArgs.Empty); + this.FindParentOfType()?.Pick(this); + } + + internal void Apply(bool isActive, EdgeMenuVariant variant) + { + _active = isActive; + _variant = variant; + Update(); + } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == IsEnabledProperty.PropertyName) + Opacity = IsEnabled ? 1.0 : 0.5; + } + + private void Update() + { + _icon.Name = Icon; + _label.Text = Text ?? string.Empty; + _label.IsVisible = !string.IsNullOrEmpty(Text); + SemanticProperties.SetDescription(this, Text); + + var hovered = _hovered && IsEnabled; + if (_variant == EdgeMenuVariant.Solid) + { + // Tiles a shade lighter than the dock; brighter when hovered or active. + var shade = _active ? 0.2f : hovered ? 0.14f : 0.08f; + _tile.ClearValue(VisualElement.BackgroundColorProperty); + _tile.BackgroundColor = ShellTheme.Get(ShellToken.PrimaryForeground).WithAlpha(shade); + _icon.Token = ShellToken.PrimaryForeground; + _label.Token(Label.TextColorProperty, ShellToken.PrimaryForeground); + _label.Opacity = _active || hovered ? 1 : 0.75; + } + else + { + if (_active || hovered) _tile.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); + else + { + _tile.ClearValue(VisualElement.BackgroundColorProperty); + _tile.BackgroundColor = Colors.Transparent; + } + _icon.Token = _active || hovered ? ShellToken.AccentForeground : ShellToken.PopoverForeground; + _label.Token(Label.TextColorProperty, _active || hovered ? ShellToken.AccentForeground : ShellToken.MutedForeground); + _label.Opacity = 1; + } + _label.FontAttributes = _active ? FontAttributes.Bold : FontAttributes.None; + } +} + +public enum EdgeMenuSide { Right, Left } + +public enum EdgeMenuVariant { Default, Solid } diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index 27aacda..55757b8 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -729,6 +729,45 @@ + +