From 982a6ba3e5d98b9ad595f477187b829a533a7145 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Mon, 5 Oct 2026 10:25:38 +0200 Subject: [PATCH 1/9] feat(components): add table, context-menu, carousel and stepper - table: header, rows with hover / selection / tap, caption, and sideways scrolling below a minimum width. Columns are Grid widths shared by every row. - context-menu: opens at the pointer on right-click and under the trigger on long-press; items take an icon, a shortcut hint and a destructive style. - carousel: swipe, arrow buttons, dots, loop and auto-play. - stepper: numbered steps with active / completed states, per-step content and built-in Previous / Next / Confirm navigation. - ShellPortal popups can open at a point inside their anchor (AnchorPoint). - Demo sections, docs and roadmap updated; DEVELOPMENT_PLAN records Phase 1c/1d as merged (PR #3) and opens Phase 1e. --- docs/COMPONENTS.md | 112 ++++++ docs/COMPONENTS_ROADMAP.md | 8 +- docs/DEVELOPMENT_PLAN.md | 40 +- examples/MAUI.Demo/Components/UI/Carousel.cs | 302 +++++++++++++++ .../MAUI.Demo/Components/UI/ContextMenu.cs | 235 +++++++++++ examples/MAUI.Demo/Components/UI/Shell.cs | 19 +- examples/MAUI.Demo/Components/UI/Stepper.cs | 279 +++++++++++++ examples/MAUI.Demo/Components/UI/Table.cs | 344 ++++++++++++++++ examples/MAUI.Demo/MainPage.xaml | 100 +++++ examples/MAUI.Demo/MainPage.xaml.cs | 15 + scripts/sync-templates.py | 8 + .../ComponentRegistry.cs | 6 +- .../Templates/CarouselTemplate.cs | 324 ++++++++++++++++ .../Templates/ContextMenuTemplate.cs | 257 ++++++++++++ .../Templates/ShellTemplate.cs | 19 +- .../Templates/StepperTemplate.cs | 301 ++++++++++++++ .../Templates/TableTemplate.cs | 366 ++++++++++++++++++ 17 files changed, 2716 insertions(+), 19 deletions(-) create mode 100644 examples/MAUI.Demo/Components/UI/Carousel.cs create mode 100644 examples/MAUI.Demo/Components/UI/ContextMenu.cs create mode 100644 examples/MAUI.Demo/Components/UI/Stepper.cs create mode 100644 examples/MAUI.Demo/Components/UI/Table.cs create mode 100644 src/ShellUI.Native.Templates/Templates/CarouselTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/ContextMenuTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/StepperTemplate.cs create mode 100644 src/ShellUI.Native.Templates/Templates/TableTemplate.cs diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index f070b56..590e1c6 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -517,6 +517,33 @@ Trail with chevron separators; links turn foreground on hover. --- +### Stepper +Step-by-step flow: numbered circles joined by lines (the active one filled, completed ones +checked), the active step's content, and Previous / Next / Confirm buttons. A step becomes +clickable once it has been reached. + +```bash +shellui-native add stepper +``` + +**Properties (Stepper):** `CurrentStep` (two-way, 0-based), `ShowNavigation` (true), +`PreviousText`, `NextText`, `ConfirmText` · **Events:** `CurrentStepChanged`, `Confirmed` · +**Methods:** `Next()`, `Previous()` + +**Properties (StepperStep):** `Title`, `Description` · its child view is the step's content + +```xml + + + + + ... + ... + +``` + +--- + ### Pagination Previous / page numbers / Next, with ellipses for skipped ranges. The current page is outlined; Previous and Next disable at the ends. @@ -551,6 +578,60 @@ Pill label. **Variants:** Default, Secondary, Outline, Destructive, Success, War Pulsing placeholder in the `Muted` token (opacity 1 → 0.5 → 1 every 2s, only while on screen). **Properties:** `CornerRadius` (default 6) plus `WidthRequest` / `HeightRequest` +### Table +Rows inside a rounded border: a header row in the muted foreground, body rows divided by a +hairline that tint on hover, and an optional caption. + +```bash +shellui-native add table +``` + +**Properties (Table):** `Columns` (Grid column widths, e.g. `2*,*,*,100`), `Caption`, `Bordered` +(true), `MinimumContentWidth` (below this width the table scrolls sideways instead of squeezing) + +**TableRow:** `IsSelected`, `Tapped` event · **TableHead:** `Text`, `HorizontalTextAlignment` · +**TableCell:** `Text`, `HorizontalTextAlignment`, `IsBold`, or any child view + +```xml + + + + + + + + + + + + + + +``` + +Use star and fixed widths in `Columns`: every row is its own grid, so an `Auto` column would size +per row. For long lists, put a `TableHeader` above a `CollectionView` whose item template is a +`TableRow` with the same `Columns` set on the row itself. + +### Carousel +One slide at a time in a rounded frame. Swipe, use the round arrow buttons or tap a dot to move. +Each child view is a slide. + +```bash +shellui-native add carousel +``` + +**Properties:** `Position` (two-way), `Loop` (true), `ShowArrows`, `ShowDots`, `AutoPlay`, +`AutoPlayInterval` (ms, default 3000), `AspectRatio` (width / height, default 16:9; `0` to size it +with `HeightRequest` instead) · **Events:** `PositionChanged` · **Methods:** `Next()`, `Previous()` + +```xml + + + + +``` + ### Empty State Placeholder for an empty list or screen: icon in a muted tile, title, description and optional actions, centered. `Bordered="True"` adds a dashed outline. @@ -739,6 +820,33 @@ shellui-native add popover ``` +### Context Menu +Right-click the trigger (long-press on touch) to open a menu at the pointer. Clicking outside, +picking an item or pressing Escape closes it. + +```bash +shellui-native add context-menu +``` + +**ContextMenuItem:** `Text`, `Icon`, `Shortcut` (hint text, display only), `IsDestructive`, +`IsEnabled` · **Events:** `Clicked` · **ContextMenuSeparator** divides groups · +`ContextMenu.OpenAt(point)` opens it from code + +```xml + + + ... + + + + + + + +``` + +On touch devices the menu opens under the trigger rather than at the finger. + ### Tooltip Small label shown above (or below) a view after the pointer rests on it. Pointer devices only. @@ -827,6 +935,10 @@ core helpers); components that draw icons also depend on `icon`. | dialog | shell, dialog-trigger, dialog-content, dialog-header, dialog-footer, dialog-title, dialog-description, dialog-close | | drawer / sheet | shell, *-trigger, *-content (content also installs icon for the close button) | | dropdown / popover | shell, *-trigger, *-content (+ dropdown-item) | +| table | shell | +| carousel | shell, icon | +| context-menu | shell, icon, element-extensions | +| stepper | shell, icon, button | | hover-card | shell, hover-card-trigger, hover-card-content | | date-picker | shell, icon, calendar | | tooltip | shell | diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md index 9b76d8e..3edf286 100644 --- a/docs/COMPONENTS_ROADMAP.md +++ b/docs/COMPONENTS_ROADMAP.md @@ -179,7 +179,7 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| | **avatar** ✅ | P5.1 | shell, icon | Avatar | Done 2026-09-27 | -| **table** | P5.2 | table-header, table-body, table-row, table-cell, table-head | Table | Data table | +| **table** ✅ | P5.2 | shell | Table | Done 2026-10-05 — one template (Table, TableHeader, TableRow, TableHead, TableCell); hover, selection, caption, sideways scroll | | **empty-state** ✅ | P5.3 | shell, icon | EmptyState | Done 2026-10-04 — icon tile, title, description, action row, optional dashed border | | **callout** ✅ | P5.4 | shell, icon | Callout | Done 2026-10-04 — Info / Warning / Danger / Tip / Default, tinted background | | **pagination** ✅ | P5.5 | shell, icon | Pagination | Done 2026-10-04 — previous/next, sibling window, ellipses | @@ -190,9 +190,9 @@ Icons: `python scripts/generate-icons.py` regenerates `Icon.cs` from the ShellIc ### P6 — Lower (Advanced) | Component | Priority | Dependencies | ShellUI Ref | Notes | |-----------|----------|--------------|-------------|-------| -| **context-menu** | P6.1 | context-menu-trigger, context-menu-content, context-menu-option | ContextMenu | Right-click / long-press menu | -| **carousel** | P6.2 | carousel-content, carousel-item, carousel-previous, carousel-next, carousel-dots | Carousel | Image/content carousel | -| **stepper** | P6.3 | stepper-list, stepper-step, stepper-content | Stepper | Step wizard | +| **context-menu** ✅ | P6.1 | shell, icon, element-extensions | ContextMenu | Done 2026-10-05 — one template; opens at the pointer on right-click, under the trigger on long-press | +| **carousel** ✅ | P6.2 | shell, icon | Carousel | Done 2026-10-05 — one template; swipe, arrows, dots, loop, auto-play | +| **stepper** ✅ | P6.3 | shell, icon, button | Stepper | Done 2026-10-05 — one template (Stepper + StepperStep) with built-in navigation | | **resizable** | P6.4 | — | Resizable | Resizable panels | | **navbar** | P6.5 | nav-trigger, nav-content, nav-list, nav-item | Navbar | Collapsible nav bar | | **sidebar** | P6.6 | — | Sidebar | App sidebar layout | diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index c6ad5d7..44c52b0 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -16,9 +16,8 @@ Short-lived sub-branches (`feat//`) cut off the phase branch and m into it, not directly into `main`. ``` -main ← Phase 1a merged (2026-07-05), Phase 1b merged (2026-08-29 via PR #2) - └─ feat/p3-navigation-layout ← Phase 1c (active) — MAUI P3 tier (tabs, accordion, …) - └─ feat/p4-overlay-portal ← Phase 1d (queued after 1c) — portal rewrite + shadcn-style controls +main ← Phase 1a (2026-07-05), 1b (2026-08-29, PR #2), 1c + 1d (2026-10-05, PR #3) merged + └─ feat/p5-p6-components ← Phase 1e (active) — MAUI P5/P6 tier (table, context menu, carousel, stepper, …) └─ feat/avalonia-implementation ← Phase 2 (planned) — Avalonia templates + reference impl └─ feat/winui ← Phase 3 (conditional) ``` @@ -36,8 +35,8 @@ main ← Phase 1a merged (2026-07-05), Phase 1b merged (2026-08-29 via PR #2) | Branch | Base | Status | Purpose | |--------|------|--------|---------| -| `main` | — | Phase 1a + 1b merged | 44 platform-keyed components, `SupportsPlatform` registry, xUnit 199/199, sizing + token contracts locked | -| `feat/p3-navigation-layout` | `main` | **Next** | Phase 1c — MAUI P3 (tabs, accordion, collapsible, breadcrumb, scroll-area, skeleton) | +| `main` | — | Phase 1a–1d merged | 76 platform-keyed components, theme tokens, overlay portal, xUnit 408/408 | +| `feat/p5-p6-components` | `main` | **Active** | Phase 1e — MAUI P5/P6 (table, context-menu, carousel, stepper; 80 components, xUnit 428/428) | --- @@ -240,7 +239,7 @@ public static class ButtonTemplate --- -## Phase 1c — `feat/p3-navigation-layout` (**in review**) +## Phase 1c — `feat/p3-navigation-layout` (**merged 2026-10-05 via [PR #3](https://github.com/shellui-dev/shellui-native/pull/3)**) Finish MAUI's component vocabulary through P3 before opening the cross-platform front. Rationale: Phase 2 (Avalonia) turns every new MAUI component into two components to @@ -317,7 +316,7 @@ nullable `Date`/`Time` + non-generic `ItemsSource` adaptations. Proper fixes → --- -## Phase 1d — `feat/p4-overlay-portal` (queued after Phase 1c) +## Phase 1d — overlay portal (**merged 2026-10-05 with Phase 1c in PR #3**; the separate `feat/p4-overlay-portal` branch was never cut) Surface polish pass driven by live testing on Windows 2026-08-30. Two distinct problems that both need architectural fixes rather than sizing tweaks. @@ -380,7 +379,32 @@ Date/Time pickers inside a themed border with the native frame stripped. --- -## Phase 2 — `feat/avalonia-implementation` (queued after Phase 1d) +## Phase 1e — `feat/p5-p6-components` (active) + +The remaining MAUI data-display and advanced tiers, built demo-first like Phase 1d. + +### Deliverables + +- [x] `table` — header, rows with hover / selection / tap, caption, sideways scroll below a minimum width +- [x] `context-menu` — opens at the pointer on right-click; long-press on touch +- [x] `carousel` — swipe, arrows, dots, loop, auto-play +- [x] `stepper` — numbered steps with completed / active states and built-in navigation +- [ ] Android + iOS pass for this batch (built for Android, not yet run on a device) +- [ ] Escape closes the overlay on top on Mac Catalyst +- [ ] `resizable`, `navbar`, `sidebar` (P6.4–P6.6) + +Each family ships as one template (e.g. `table` holds Table, TableHeader, TableRow, TableHead +and TableCell) rather than one template per part. + +### Exit criteria + +1. Every new component clicked through in `MAUI.Demo` on Windows and on an Android device +2. `dotnet build` clean for the Windows and Android targets, `dotnet test` green +3. Docs and roadmap updated + +--- + +## Phase 2 — `feat/avalonia-implementation` (queued after Phase 1e) Cross-desktop (Windows + macOS + Linux) from one XAML codebase. diff --git a/examples/MAUI.Demo/Components/UI/Carousel.cs b/examples/MAUI.Demo/Components/UI/Carousel.cs new file mode 100644 index 0000000..ad354e1 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Carousel.cs @@ -0,0 +1,302 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Carousel — one slide at a time in a rounded-lg frame; swipe, use the round arrow buttons or +// tap a dot to move. Each child view is a slide. +// +// +// +// +[ContentProperty(nameof(Slides))] +public partial class Carousel : ContentView +{ + public static readonly BindableProperty PositionProperty = + BindableProperty.Create(nameof(Position), typeof(int), typeof(Carousel), 0, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Carousel)b).OnPositionChanged()); + + // At the last slide, Next wraps to the first (and Previous the other way). + public static readonly BindableProperty LoopProperty = + BindableProperty.Create(nameof(Loop), typeof(bool), typeof(Carousel), true, + propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls()); + + public static readonly BindableProperty ShowArrowsProperty = + BindableProperty.Create(nameof(ShowArrows), typeof(bool), typeof(Carousel), true, + propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls()); + + public static readonly BindableProperty ShowDotsProperty = + BindableProperty.Create(nameof(ShowDots), typeof(bool), typeof(Carousel), true, + propertyChanged: (b, o, n) => ((Carousel)b).UpdateControls()); + + public static readonly BindableProperty AutoPlayProperty = + BindableProperty.Create(nameof(AutoPlay), typeof(bool), typeof(Carousel), false, + propertyChanged: (b, o, n) => ((Carousel)b).UpdateTimer()); + + // Milliseconds between slides while AutoPlay is on. + public static readonly BindableProperty AutoPlayIntervalProperty = + BindableProperty.Create(nameof(AutoPlayInterval), typeof(int), typeof(Carousel), 3000, + propertyChanged: (b, o, n) => ((Carousel)b).UpdateTimer()); + + // Width / height of the frame. 0 turns it off: set HeightRequest on the carousel instead. + public static readonly BindableProperty AspectRatioProperty = + BindableProperty.Create(nameof(AspectRatio), typeof(double), typeof(Carousel), 16d / 9d, + propertyChanged: (b, o, n) => ((Carousel)b).Resize()); + + public int Position + { + get => (int)GetValue(PositionProperty); + set => SetValue(PositionProperty, value); + } + + public bool Loop + { + get => (bool)GetValue(LoopProperty); + set => SetValue(LoopProperty, value); + } + + public bool ShowArrows + { + get => (bool)GetValue(ShowArrowsProperty); + set => SetValue(ShowArrowsProperty, value); + } + + public bool ShowDots + { + get => (bool)GetValue(ShowDotsProperty); + set => SetValue(ShowDotsProperty, value); + } + + public bool AutoPlay + { + get => (bool)GetValue(AutoPlayProperty); + set => SetValue(AutoPlayProperty, value); + } + + public int AutoPlayInterval + { + get => (int)GetValue(AutoPlayIntervalProperty); + set => SetValue(AutoPlayIntervalProperty, value); + } + + public double AspectRatio + { + get => (double)GetValue(AspectRatioProperty); + set => SetValue(AspectRatioProperty, value); + } + + public IList Slides => _track.Children; + + public event EventHandler? PositionChanged; + + private const string SlideAnimation = "CarouselSlide"; + + private readonly Grid _track; + private readonly Border _frame; + private readonly Border _previous; + private readonly Border _next; + private readonly HorizontalStackLayout _dots; + private IDispatcherTimer? _timer; + // Where the track is, in slides: 1.5 is halfway between the second and third slide. + private double _offset; + private double _dragX; + + public Carousel() + { + _track = new Grid { IsClippedToBounds = true, BackgroundColor = Colors.Transparent }; + _track.ChildAdded += (_, _) => OnSlidesChanged(); + _track.ChildRemoved += (_, _) => OnSlidesChanged(); + var pan = new PanGestureRecognizer(); + pan.PanUpdated += OnPan; + _track.GestureRecognizers.Add(pan); + + _frame = new Border + { + Content = _track, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusLg }, + BackgroundColor = Colors.Transparent + }; + + _previous = Arrow(IconName.ChevronLeft, LayoutOptions.Start, "Previous slide", Previous); + _next = Arrow(IconName.ChevronRight, LayoutOptions.End, "Next slide", Next); + _dots = new HorizontalStackLayout + { + Spacing = 0, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.End, + Margin = new Thickness(0, 0, 0, 8) + }; + + Content = new Grid { Children = { _frame, _previous, _next, _dots } }; + SizeChanged += (_, _) => Resize(); + Loaded += (_, _) => UpdateTimer(); + Unloaded += (_, _) => StopTimer(); + 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; + } + + private Border Arrow(IconName icon, LayoutOptions side, string description, Action press) + { + var button = new Border + { + Content = new Icon { Name = icon, Size = 16, Token = ShellToken.Foreground }, + WidthRequest = 32, + HeightRequest = 32, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = 16 }, + HorizontalOptions = side, + VerticalOptions = LayoutOptions.Center, + Margin = new Thickness(12, 0), + Shadow = new Shadow { Brush = new SolidColorBrush(Colors.Black), Offset = new Point(0, 2), Radius = 6, Opacity = 0.15f } + }; + button.Token(VisualElement.BackgroundColorProperty, ShellToken.Background); + button.Token(Border.StrokeProperty, ShellToken.Border); + SemanticProperties.SetDescription(button, description); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => press(); + button.GestureRecognizers.Add(tap); + return button; + } + + private void OnSlidesChanged() + { + _dots.Children.Clear(); + for (var i = 0; i < Count; i++) + { + var index = i; + var dot = new Border + { + WidthRequest = 8, + HeightRequest = 8, + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = 4 }, + HorizontalOptions = LayoutOptions.Center, + VerticalOptions = LayoutOptions.Center + }; + dot.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary); + // 8px dot in a 16px tap target. + var target = new Grid { WidthRequest = 16, HeightRequest = 16, BackgroundColor = Colors.Transparent, Children = { dot } }; + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Position = index; + target.GestureRecognizers.Add(tap); + _dots.Children.Add(target); + } + _offset = Current; + Render(); + UpdateControls(); + } + + private void OnPositionChanged() + { + UpdateControls(); + this.AbortAnimation(SlideAnimation); + new Animation(v => { _offset = v; Render(); }, _offset, Current) + .Commit(this, SlideAnimation, 16, 300, Easing.CubicOut); + PositionChanged?.Invoke(this, Current); + // A manual move restarts the auto-play countdown. + UpdateTimer(); + } + + private void Resize() + { + if (AspectRatio > 0 && Width > 0) _frame.HeightRequest = Width / AspectRatio; + else if (AspectRatio <= 0) _frame.HeightRequest = -1; + Render(); + } + + // Slides are stacked in one cell and spread out sideways by translation. + private void Render() + { + var width = _track.Width > 0 ? _track.Width : Width; + if (width <= 0) return; + for (var i = 0; i < Count; i++) + { + if (_track.Children[i] is View slide) + slide.TranslationX = (i - _offset) * width; + } + } + + private void UpdateControls() + { + var current = Current; + var many = Count > 1; + _previous.IsVisible = ShowArrows && many; + _next.IsVisible = ShowArrows && many; + _previous.Opacity = Loop || current > 0 ? 0.9 : 0.4; + _next.Opacity = Loop || current < Count - 1 ? 0.9 : 0.4; + _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 Grid { Children: [Border dot] }) + dot.Opacity = i == current ? 1 : 0.3; + } + } + + private void OnPan(object? sender, PanUpdatedEventArgs e) + { + var width = _track.Width; + if (width <= 0 || Count < 2) return; + switch (e.StatusType) + { + case GestureStatus.Started: + this.AbortAnimation(SlideAnimation); + _dragX = 0; + break; + case GestureStatus.Running: + // Follow the finger, with a little give past the first and last slide. + _dragX = e.TotalX; + _offset = Math.Clamp(Current - _dragX / width, -0.25, Count - 0.75); + Render(); + break; + case GestureStatus.Completed: + case GestureStatus.Canceled: + var moved = _dragX / width; + _dragX = 0; + var current = Current; + if (moved <= -0.2 && current < Count - 1) Position = current + 1; + else if (moved >= 0.2 && current > 0) Position = current - 1; + else + { + // Not far enough: settle back. + new Animation(v => { _offset = v; Render(); }, _offset, current) + .Commit(this, SlideAnimation, 16, 200, Easing.CubicOut); + } + break; + } + } + + private void UpdateTimer() + { + StopTimer(); + if (!AutoPlay || Count < 2 || Handler is null) return; + _timer = Dispatcher.CreateTimer(); + _timer.Interval = TimeSpan.FromMilliseconds(Math.Max(500, AutoPlayInterval)); + _timer.Tick += (_, _) => + { + if (Current < Count - 1) Position = Current + 1; + else Position = 0; + }; + _timer.Start(); + } + + private void StopTimer() + { + _timer?.Stop(); + _timer = null; + } +} diff --git a/examples/MAUI.Demo/Components/UI/ContextMenu.cs b/examples/MAUI.Demo/Components/UI/ContextMenu.cs new file mode 100644 index 0000000..33c2f56 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/ContextMenu.cs @@ -0,0 +1,235 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.Components.UI; + +// Context menu — right-click (long-press on touch) the trigger to open a menu at the pointer. +// Clicking outside, picking an item or pressing Escape closes it. +// +// ...any view... +// +// +// +// +// +// +public partial class ContextMenu : ShellPopoverHost +{ + private Point? _point; + + public ContextMenu() + { + // The trigger usually is a surface (a card, a row): let it fill its container. + HorizontalOptions = LayoutOptions.Fill; + } + + protected override bool IsContent(Element child) => child is ContextMenuContent; + protected override Point? AnchorPoint => _point; + protected override double Offset => _point is null ? 4 : 0; + + // Opens the menu at a point relative to this view, or below the trigger when null. + public void OpenAt(Point? point) + { + if (IsOpen) IsOpen = false; + _point = point; + IsOpen = true; + } +} + +// The surface that opens the enclosing ContextMenu on right-click / long-press. +public partial class ContextMenuTrigger : ContentView +{ + private object? _hooked; + + public ContextMenuTrigger() + { + var secondary = new TapGestureRecognizer { Buttons = ButtonsMask.Secondary }; + secondary.Tapped += (_, e) => + { + var menu = this.FindParentOfType(); + menu?.OpenAt(e.GetPosition(menu)); + }; + GestureRecognizers.Add(secondary); + HandlerChanged += (_, _) => HookLongPress(); + } + + private void Open(Point? point) => this.FindParentOfType()?.OpenAt(point); + + private void HookLongPress() + { + var native = Handler?.PlatformView; + if (native is null || ReferenceEquals(native, _hooked)) return; + _hooked = native; +#if ANDROID + if (native is Android.Views.View view) + { + view.LongClickable = true; + view.LongClick += (_, e) => { e.Handled = true; Open(null); }; + } +#elif IOS || MACCATALYST + if (native is UIKit.UIView view) + { + view.AddGestureRecognizer(new UIKit.UILongPressGestureRecognizer(gesture => + { + if (gesture.State == UIKit.UIGestureRecognizerState.Began) Open(null); + })); + } +#endif + } +} + +// Menu panel — min-w-[13rem] rounded-md border bg-popover p-1 shadow-md. +[ContentProperty(nameof(Children))] +public partial class ContextMenuContent : ContentView +{ + private readonly VerticalStackLayout _stack; + + public new IList Children => _stack.Children; + + public ContextMenuContent() + { + _stack = new VerticalStackLayout { Spacing = 0 }; + var panel = new Border + { + Content = _stack, + Padding = new Thickness(4), + MinimumWidthRequest = 208, + StrokeThickness = 1, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + Shadow = ShellPopups.PanelShadow() + }; + panel.Token(VisualElement.BackgroundColorProperty, ShellToken.Popover); + panel.Token(Border.StrokeProperty, ShellToken.Border); + // Taps on the panel padding must not fall through to the click-outside catcher. + panel.GestureRecognizers.Add(new TapGestureRecognizer()); + Content = panel; + } +} + +// Menu row — rounded-sm px-2 py-1.5 text-sm, hover:bg-accent, optional icon and shortcut hint. +public partial class ContextMenuItem : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(ContextMenuItem), string.Empty, + propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update()); + + public static readonly BindableProperty IconProperty = + BindableProperty.Create(nameof(Icon), typeof(IconName), typeof(ContextMenuItem), IconName.None, + propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update()); + + // Keyboard hint shown at the end of the row (display only). + public static readonly BindableProperty ShortcutProperty = + BindableProperty.Create(nameof(Shortcut), typeof(string), typeof(ContextMenuItem), string.Empty, + propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update()); + + public static readonly BindableProperty IsDestructiveProperty = + BindableProperty.Create(nameof(IsDestructive), typeof(bool), typeof(ContextMenuItem), false, + propertyChanged: (b, o, n) => ((ContextMenuItem)b).Update()); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public IconName Icon + { + get => (IconName)GetValue(IconProperty); + set => SetValue(IconProperty, value); + } + + public string Shortcut + { + get => (string)GetValue(ShortcutProperty); + set => SetValue(ShortcutProperty, value); + } + + public bool IsDestructive + { + get => (bool)GetValue(IsDestructiveProperty); + set => SetValue(IsDestructiveProperty, value); + } + + public event EventHandler? Clicked; + + private readonly Border _row; + private readonly Icon _icon; + private readonly Label _label; + private readonly Label _shortcut; + + public ContextMenuItem() + { + _icon = new Icon { Size = 16, IsVisible = false, VerticalOptions = LayoutOptions.Center }; + _label = new Label { FontSize = 14, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center }; + _shortcut = new Label { FontSize = 12, VerticalOptions = LayoutOptions.Center, VerticalTextAlignment = TextAlignment.Center, IsVisible = false }; + _shortcut.Token(Label.TextColorProperty, ShellToken.MutedForeground); + + var grid = new Grid + { + ColumnSpacing = 8, + ColumnDefinitions = + { + new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto) + } + }; + grid.Add(_icon, 0, 0); + grid.Add(_label, 1, 0); + grid.Add(_shortcut, 2, 0); + + _row = new Border + { + Content = grid, + HeightRequest = 32, + Padding = new Thickness(8, 0), + StrokeThickness = 0, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusSm }, + BackgroundColor = Colors.Transparent + }; + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { if (IsEnabled) _row.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); }; + pointer.PointerExited += (_, _) => { _row.ClearValue(VisualElement.BackgroundColorProperty); _row.BackgroundColor = Colors.Transparent; }; + _row.GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Press(); + _row.GestureRecognizers.Add(tap); + + Content = _row; + Update(); + } + + protected override void OnPropertyChanged(string? propertyName = null) + { + base.OnPropertyChanged(propertyName); + if (propertyName == nameof(IsEnabled)) Opacity = IsEnabled ? 1 : 0.5; + } + + private void Press() + { + if (!IsEnabled) return; + this.FindParentOfType()?.Close(); + Clicked?.Invoke(this, EventArgs.Empty); + } + + private void Update() + { + var color = IsDestructive ? ShellToken.Destructive : ShellToken.PopoverForeground; + _icon.Name = Icon; + _icon.IsVisible = Icon != IconName.None; + _icon.Token = color; + _label.Text = Text ?? string.Empty; + _label.Token(Label.TextColorProperty, 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 partial class ContextMenuSeparator : ContentView +{ + public ContextMenuSeparator() + { + var line = new BoxView { HeightRequest = 1, BackgroundColor = Colors.Transparent }; + line.Token(BoxView.ColorProperty, ShellToken.Border); + Margin = new Thickness(-4, 4); + Content = line; + } +} diff --git a/examples/MAUI.Demo/Components/UI/Shell.cs b/examples/MAUI.Demo/Components/UI/Shell.cs index ee882cc..341e06f 100644 --- a/examples/MAUI.Demo/Components/UI/Shell.cs +++ b/examples/MAUI.Demo/Components/UI/Shell.cs @@ -630,6 +630,13 @@ private static async Task PlaceAsync(View anchor, View panel, Grid layer, ShellP var size = new Size(panel.Width, panel.Height); var origin = GetPosition(anchor, layer); + var anchorSize = new Size(anchor.Width, anchor.Height); + if (options.AnchorPoint is { } point) + { + // Open at a point inside the anchor (context menus): a zero-size anchor there. + origin = new Point(origin.X + point.X, origin.Y + point.Y); + anchorSize = Size.Zero; + } const double edge = 8; var safe = GetSafeInsets(layer); var minTop = safe.Top + edge; @@ -637,11 +644,11 @@ private static async Task PlaceAsync(View anchor, View panel, Grid layer, ShellP var x = options.Align switch { - ShellPopupAlign.Center => origin.X + (anchor.Width - size.Width) / 2, - ShellPopupAlign.End => origin.X + anchor.Width - size.Width, + ShellPopupAlign.Center => origin.X + (anchorSize.Width - size.Width) / 2, + ShellPopupAlign.End => origin.X + anchorSize.Width - size.Width, _ => origin.X }; - var below = origin.Y + anchor.Height + options.Offset; + var below = origin.Y + anchorSize.Height + options.Offset; var above = origin.Y - size.Height - options.Offset; var fitsBelow = below + size.Height <= maxBottom; var fitsAbove = above >= minTop; @@ -670,6 +677,8 @@ public sealed class ShellPopupOptions public ShellPopupAlign Align { get; init; } = ShellPopupAlign.Start; public double Offset { get; init; } = 4; public bool MatchAnchorWidth { get; init; } + // Position inside the anchor to open at instead of its edge (e.g. where the user right-clicked). + public Point? AnchorPoint { get; init; } // Modal popups get a click-outside catcher (menus, selects); tooltips and hover cards don't. public bool Modal { get; init; } = true; public Action? OnDismiss { get; init; } @@ -850,6 +859,8 @@ protected ShellPopoverHost() protected virtual bool Modal => true; protected virtual ShellPopupPlacement Placement => ShellPopupPlacement.Bottom; protected virtual ShellPopupAlign Align => ShellPopupAlign.Start; + protected virtual Point? AnchorPoint => null; + protected virtual double Offset => 4; protected View? PopupContent => _content; @@ -878,6 +889,8 @@ private void OnOpenChanged() { Placement = Placement, Align = Align, + Offset = Offset, + AnchorPoint = AnchorPoint, Modal = Modal, Owner = this, OnDismiss = Close diff --git a/examples/MAUI.Demo/Components/UI/Stepper.cs b/examples/MAUI.Demo/Components/UI/Stepper.cs new file mode 100644 index 0000000..d1512e6 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Stepper.cs @@ -0,0 +1,279 @@ +using System.Collections.ObjectModel; +using Microsoft.Maui.Controls.Shapes; +using MAUI.Demo.Components.UI.Variants; + +namespace MAUI.Demo.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 once it +// has been reached. +// +// ...any view... +// ... +// ... +// +[ContentProperty(nameof(Steps))] +public partial class Stepper : ContentView +{ + public static readonly BindableProperty CurrentStepProperty = + BindableProperty.Create(nameof(CurrentStep), typeof(int), typeof(Stepper), 0, BindingMode.TwoWay, + propertyChanged: (b, o, n) => ((Stepper)b).OnCurrentStepChanged()); + + public static readonly BindableProperty ShowNavigationProperty = + BindableProperty.Create(nameof(ShowNavigation), typeof(bool), typeof(Stepper), true, + propertyChanged: (b, o, n) => ((Stepper)b).UpdateState()); + + public static readonly BindableProperty PreviousTextProperty = + BindableProperty.Create(nameof(PreviousText), typeof(string), typeof(Stepper), "Previous", + propertyChanged: (b, o, n) => ((Stepper)b).UpdateState()); + + public static readonly BindableProperty NextTextProperty = + BindableProperty.Create(nameof(NextText), typeof(string), typeof(Stepper), "Next", + propertyChanged: (b, o, n) => ((Stepper)b).UpdateState()); + + public static readonly BindableProperty ConfirmTextProperty = + BindableProperty.Create(nameof(ConfirmText), typeof(string), typeof(Stepper), "Confirm", + propertyChanged: (b, o, n) => ((Stepper)b).UpdateState()); + + public int CurrentStep + { + get => (int)GetValue(CurrentStepProperty); + set => SetValue(CurrentStepProperty, value); + } + + public bool ShowNavigation + { + get => (bool)GetValue(ShowNavigationProperty); + set => SetValue(ShowNavigationProperty, value); + } + + public string PreviousText + { + get => (string)GetValue(PreviousTextProperty); + set => SetValue(PreviousTextProperty, value); + } + + public string NextText + { + get => (string)GetValue(NextTextProperty); + set => SetValue(NextTextProperty, value); + } + + public string ConfirmText + { + get => (string)GetValue(ConfirmTextProperty); + set => SetValue(ConfirmTextProperty, value); + } + + public IList Steps => _steps; + + public event EventHandler? CurrentStepChanged; + // Raised by the Confirm button on the last step. + public event EventHandler? Confirmed; + + private const double CircleSize = 32; + + private sealed class Marker(Border circle, Label number, Icon check, Label title, BoxView before, BoxView after) + { + public Border Circle { get; } = circle; + public Label Number { get; } = number; + public Icon Check { get; } = check; + public Label Title { get; } = title; + public BoxView Before { get; } = before; + public BoxView After { get; } = after; + } + + private readonly ObservableCollection _steps = new(); + private readonly List _markers = new(); + private readonly Grid _header; + private readonly ContentView _body; + private readonly Grid _navigation; + private readonly Button _previous; + private readonly Button _next; + private int _highest; + + public Stepper() + { + _header = new Grid(); + _body = new ContentView { Margin = new Thickness(0, 24, 0, 0) }; + + _previous = new Button { Variant = ButtonVariant.Outline, HorizontalOptions = LayoutOptions.Start }; + _previous.Clicked += (_, _) => Previous(); + _next = new Button { HorizontalOptions = LayoutOptions.End }; + _next.Clicked += (_, _) => + { + if (IsLast) + { + _highest = Math.Max(_highest, _steps.Count - 1); + Confirmed?.Invoke(this, EventArgs.Empty); + } + else Next(); + }; + _navigation = new Grid { Margin = new Thickness(0, 24, 0, 0), Children = { _previous, _next } }; + + Content = new VerticalStackLayout { Spacing = 0, Children = { _header, _body, _navigation } }; + _steps.CollectionChanged += (_, _) => Rebuild(); + Rebuild(); + } + + private int Current => Math.Clamp(CurrentStep, 0, Math.Max(0, _steps.Count - 1)); + private bool IsLast => Current >= _steps.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() + { + _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 Label + { + Text = (i + 1).ToString(), + FontSize = 14, + FontAttributes = FontAttributes.Bold, + HorizontalTextAlignment = TextAlignment.Center, + VerticalTextAlignment = TextAlignment.Center + }; + var check = new Icon { Name = IconName.Check, Size = 16, Token = ShellToken.Primary, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center }; + var circle = new Border + { + Content = new Grid { Children = { number, check } }, + WidthRequest = CircleSize, + HeightRequest = CircleSize, + StrokeThickness = 2, + StrokeShape = new RoundRectangle { CornerRadius = CircleSize / 2 } + }; + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => { if (index <= _highest) CurrentStep = index; }; + circle.GestureRecognizers.Add(tap); + + // 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 ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Auto), new ColumnDefinition(GridLength.Star) } + }; + rail.Add(before, 0, 0); + rail.Add(circle, 1, 0); + rail.Add(after, 2, 0); + + var title = new Label + { + Text = step.Title, + FontSize = 14, + FontAttributes = FontAttributes.Bold, + HorizontalTextAlignment = TextAlignment.Center, + Margin = new Thickness(4, 8, 4, 0), + IsVisible = !string.IsNullOrEmpty(step.Title) + }; + var description = new Label + { + Text = step.Description, + FontSize = 12, + HorizontalTextAlignment = TextAlignment.Center, + Margin = new Thickness(4, 2, 4, 0), + IsVisible = !string.IsNullOrEmpty(step.Description) + }; + description.Token(Label.TextColorProperty, ShellToken.MutedForeground); + + _header.ColumnDefinitions.Add(new ColumnDefinition(GridLength.Star)); + _header.Add(new VerticalStackLayout { Spacing = 0, Children = { rail, title, description } }, i, 0); + _markers.Add(new Marker(circle, number, check, title, before, after)); + } + + _highest = Math.Min(_highest, Math.Max(0, _steps.Count - 1)); + UpdateState(); + + static BoxView Line(bool visible) => new() + { + HeightRequest = 2, + BackgroundColor = Colors.Transparent, + VerticalOptions = LayoutOptions.Center, + Opacity = visible ? 1 : 0 + }; + } + + private void OnStepPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName is nameof(StepperStep.Title) or nameof(StepperStep.Description)) Rebuild(); + } + + private void UpdateState() + { + var current = Current; + for (var i = 0; i < _markers.Count; i++) + { + var marker = _markers[i]; + var active = i == current; + var completed = i < _highest && !active; + + if (active) marker.Circle.Token(VisualElement.BackgroundColorProperty, ShellToken.Primary); + else + { + marker.Circle.ClearValue(VisualElement.BackgroundColorProperty); + marker.Circle.BackgroundColor = Colors.Transparent; + } + marker.Circle.Token(Border.StrokeProperty, active || completed ? ShellToken.Primary : ShellToken.Border); + marker.Number.IsVisible = !completed; + marker.Number.Token(Label.TextColorProperty, active ? ShellToken.PrimaryForeground : ShellToken.MutedForeground); + marker.Check.IsVisible = completed; + marker.Title.Token(Label.TextColorProperty, active || completed ? ShellToken.Foreground : ShellToken.MutedForeground); + marker.Before.Token(BoxView.ColorProperty, i <= _highest ? ShellToken.Primary : ShellToken.Border); + marker.After.Token(BoxView.ColorProperty, i < _highest ? ShellToken.Primary : ShellToken.Border); + } + + _body.Content = _steps.Count > 0 ? _steps[current] : null; + + _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 partial class StepperStep : ContentView +{ + public static readonly BindableProperty TitleProperty = + BindableProperty.Create(nameof(Title), typeof(string), typeof(StepperStep), string.Empty); + + public static readonly BindableProperty DescriptionProperty = + BindableProperty.Create(nameof(Description), typeof(string), typeof(StepperStep), string.Empty); + + public string Title + { + get => (string)GetValue(TitleProperty); + set => SetValue(TitleProperty, value); + } + + public string Description + { + get => (string)GetValue(DescriptionProperty); + set => SetValue(DescriptionProperty, value); + } +} diff --git a/examples/MAUI.Demo/Components/UI/Table.cs b/examples/MAUI.Demo/Components/UI/Table.cs new file mode 100644 index 0000000..ce500d9 --- /dev/null +++ b/examples/MAUI.Demo/Components/UI/Table.cs @@ -0,0 +1,344 @@ +using Microsoft.Maui.Controls.Shapes; + +namespace MAUI.Demo.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 a CollectionView under a TableHeader. +[ContentProperty(nameof(Rows))] +public partial class Table : ContentView +{ + public static readonly BindableProperty ColumnsProperty = + BindableProperty.Create(nameof(Columns), typeof(string), typeof(Table), string.Empty, + propertyChanged: (b, o, n) => ((Table)b).Refresh()); + + public static readonly BindableProperty CaptionProperty = + BindableProperty.Create(nameof(Caption), typeof(string), typeof(Table), string.Empty, + propertyChanged: (b, o, n) => ((Table)b).UpdateChrome()); + + public static readonly BindableProperty BorderedProperty = + BindableProperty.Create(nameof(Bordered), typeof(bool), typeof(Table), true, + propertyChanged: (b, o, n) => ((Table)b).UpdateChrome()); + + // Narrower than this, the table scrolls horizontally instead of squeezing its columns. + public static readonly BindableProperty MinimumContentWidthProperty = + BindableProperty.Create(nameof(MinimumContentWidth), typeof(double), typeof(Table), 0d, + propertyChanged: (b, o, n) => ((Table)b).UpdateScrolling()); + + public string Columns + { + get => (string)GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + + public string Caption + { + get => (string)GetValue(CaptionProperty); + set => SetValue(CaptionProperty, value); + } + + public bool Bordered + { + get => (bool)GetValue(BorderedProperty); + set => SetValue(BorderedProperty, value); + } + + public double MinimumContentWidth + { + get => (double)GetValue(MinimumContentWidthProperty); + set => SetValue(MinimumContentWidthProperty, value); + } + + public IList Rows => _rows.Children; + + private readonly VerticalStackLayout _rows; + private readonly ScrollView _scroll; + private readonly Border _frame; + private readonly Label _caption; + + public Table() + { + _rows = new VerticalStackLayout { Spacing = 0 }; + _rows.ChildAdded += (_, _) => Refresh(); + _rows.ChildRemoved += (_, _) => Refresh(); + _scroll = new ScrollView { Orientation = ScrollOrientation.Horizontal }; + + _frame = new Border + { + Content = _rows, + StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }, + BackgroundColor = Colors.Transparent + }; + _frame.Token(Border.StrokeProperty, ShellToken.Border); + _frame.SizeChanged += (_, _) => FitScrollContent(); + + _caption = new Label { FontSize = 14, HorizontalTextAlignment = TextAlignment.Center, Margin = new Thickness(0, 12, 0, 0) }; + _caption.Token(Label.TextColorProperty, ShellToken.MutedForeground); + + Content = new VerticalStackLayout { Spacing = 0, Children = { _frame, _caption } }; + Loaded += (_, _) => Refresh(); + 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) + { + var invariant = System.Globalization.CultureInfo.InvariantCulture; + 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, System.Globalization.NumberStyles.Float, invariant, out var stars) ? stars : 1, GridUnitType.Star); + } + return double.TryParse(part, System.Globalization.NumberStyles.Float, invariant, out var width) ? new GridLength(width) : GridLength.Star; + } + } + + // Rows don't know their place in the table: the table hands them 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.StrokeThickness = Bordered ? 1 : 0; + _caption.Text = Caption ?? string.Empty; + _caption.IsVisible = !string.IsNullOrEmpty(Caption); + } + + private void UpdateScrolling() + { + var scrolls = MinimumContentWidth > 0; + if (scrolls && _frame.Content != _scroll) + { + _frame.Content = null; + _scroll.Content = _rows; + _frame.Content = _scroll; + } + else if (!scrolls && _frame.Content != _rows) + { + _scroll.Content = null; + _frame.Content = _rows; + _rows.WidthRequest = -1; + } + FitScrollContent(); + } + + // Inside a horizontal ScrollView the rows would measure at their minimum width, so they + // are given the larger of the visible width and the minimum. + private void FitScrollContent() + { + if (MinimumContentWidth <= 0 || _frame.Width <= 0) return; + _rows.WidthRequest = Math.Max(_frame.Width - 2 * _frame.StrokeThickness, MinimumContentWidth); + } +} + +// Body row — border-b, hover:bg-muted/50, bg-muted when selected. Its children are the cells, +// placed in columns in order. +public partial class TableRow : Grid +{ + // Set only for rows used outside a Table (e.g. a CollectionView item template). + public static readonly BindableProperty ColumnsProperty = + BindableProperty.Create(nameof(Columns), typeof(string), typeof(TableRow), string.Empty, + propertyChanged: (b, o, n) => ((TableRow)b).Apply(Table.ParseColumns((string)n))); + + public static readonly BindableProperty IsSelectedProperty = + BindableProperty.Create(nameof(IsSelected), typeof(bool), typeof(TableRow), false, + propertyChanged: (b, o, n) => ((TableRow)b).UpdateFill()); + + public string Columns + { + get => (string)GetValue(ColumnsProperty); + set => SetValue(ColumnsProperty, value); + } + + public bool IsSelected + { + get => (bool)GetValue(IsSelectedProperty); + set => SetValue(IsSelectedProperty, value); + } + + public event EventHandler? Tapped; + + private readonly BoxView _fill; + private readonly BoxView _divider; + private IReadOnlyList _widths = Array.Empty(); + private bool _hovered; + + internal bool ShowDivider + { + get => _divider.IsVisible; + set => _divider.IsVisible = value; + } + + protected virtual bool Interactive => true; + + public TableRow() + { + _fill = new BoxView { BackgroundColor = Colors.Transparent, Opacity = 0, InputTransparent = true, ZIndex = -1 }; + _fill.Token(BoxView.ColorProperty, ShellToken.Muted); + _divider = new BoxView { BackgroundColor = Colors.Transparent, HeightRequest = 1, VerticalOptions = LayoutOptions.End, InputTransparent = true }; + _divider.Token(BoxView.ColorProperty, ShellToken.Border); + Children.Add(_fill); + Children.Add(_divider); + // A layout with no background only takes pointer input over its children. + BackgroundColor = Colors.Transparent; + + if (Interactive) + { + var pointer = new PointerGestureRecognizer(); + pointer.PointerEntered += (_, _) => { _hovered = true; UpdateFill(); }; + pointer.PointerExited += (_, _) => { _hovered = false; UpdateFill(); }; + GestureRecognizers.Add(pointer); + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => Tapped?.Invoke(this, EventArgs.Empty); + GestureRecognizers.Add(tap); + } + } + + protected override void OnChildAdded(Element child) + { + base.OnChildAdded(child); + // _divider is still null while the constructor adds the row's own chrome. + if (_divider != null && child != _fill && child != _divider) Apply(_widths); + } + + 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++) + Grid.SetColumn((BindableObject)cells[i], i); + Grid.SetColumnSpan(_fill, count); + Grid.SetColumnSpan(_divider, count); + } + + private void UpdateFill() => _fill.Opacity = IsSelected ? 1 : _hovered ? 0.5 : 0; +} + +// Header row: holds TableHead cells, no hover. +public partial class TableHeader : TableRow +{ + protected override bool Interactive => false; +} + +// Header cell — h-11 px-4 font-medium text-muted-foreground. +public partial class TableHead : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(TableHead), string.Empty, + propertyChanged: (b, o, n) => ((TableHead)b)._label.Text = n as string ?? string.Empty); + + public static readonly BindableProperty HorizontalTextAlignmentProperty = + BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(TableHead), TextAlignment.Start, + propertyChanged: (b, o, n) => ((TableHead)b)._label.HorizontalTextAlignment = (TextAlignment)n); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public TextAlignment HorizontalTextAlignment + { + get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + private readonly Label _label; + + public TableHead() + { + _label = new Label + { + FontSize = 14, + FontAttributes = FontAttributes.Bold, + VerticalTextAlignment = TextAlignment.Center, + LineBreakMode = LineBreakMode.TailTruncation + }; + _label.Token(Label.TextColorProperty, ShellToken.MutedForeground); + HeightRequest = 44; + Padding = new Thickness(16, 0); + Content = _label; + } +} + +// Body cell — p-4 text-sm. Set Text, or put any view inside. +public partial class TableCell : ContentView +{ + public static readonly BindableProperty TextProperty = + BindableProperty.Create(nameof(Text), typeof(string), typeof(TableCell), string.Empty, + propertyChanged: (b, o, n) => ((TableCell)b)._label.Text = n as string ?? string.Empty); + + public static readonly BindableProperty HorizontalTextAlignmentProperty = + BindableProperty.Create(nameof(HorizontalTextAlignment), typeof(TextAlignment), typeof(TableCell), TextAlignment.Start, + propertyChanged: (b, o, n) => ((TableCell)b)._label.HorizontalTextAlignment = (TextAlignment)n); + + public static readonly BindableProperty IsBoldProperty = + BindableProperty.Create(nameof(IsBold), typeof(bool), typeof(TableCell), false, + propertyChanged: (b, o, n) => ((TableCell)b)._label.FontAttributes = (bool)n ? FontAttributes.Bold : FontAttributes.None); + + public string Text + { + get => (string)GetValue(TextProperty); + set => SetValue(TextProperty, value); + } + + public TextAlignment HorizontalTextAlignment + { + get => (TextAlignment)GetValue(HorizontalTextAlignmentProperty); + set => SetValue(HorizontalTextAlignmentProperty, value); + } + + public bool IsBold + { + get => (bool)GetValue(IsBoldProperty); + set => SetValue(IsBoldProperty, value); + } + + private readonly Label _label; + + public TableCell() + { + _label = new Label + { + FontSize = 14, + VerticalTextAlignment = TextAlignment.Center, + VerticalOptions = LayoutOptions.Center, + LineBreakMode = LineBreakMode.TailTruncation + }; + _label.Token(Label.TextColorProperty, ShellToken.Foreground); + Padding = new Thickness(16, 12); + Content = _label; + } +} diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml index da5069b..3cc7fe3 100644 --- a/examples/MAUI.Demo/MainPage.xaml +++ b/examples/MAUI.Demo/MainPage.xaml @@ -340,6 +340,26 @@ + +