diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md
index e7ef8d7..719e72e 100644
--- a/docs/COMPONENTS.md
+++ b/docs/COMPONENTS.md
@@ -112,6 +112,8 @@ Same names, properties and variants as MAUI, except:
| Carousel | Swipe, arrows, dots | Drag with the mouse or touch; the arrows are tab stops and Left / Right move while one has focus; with `AspectRatio="0"` set `Height` |
| Stepper | Shows the active step's view | Every step stays in the tree and only the active one is visible; reached steps are tab stops (Enter / Space) |
| Pagination | Labels hidden on phones | `ShowLabels` defaults to true; every page and arrow is a tab stop (Enter / Space) |
+| ImageViewer | `Source` is an `ImageSource`; `Aspect`; pinch zooms | `Source` is an `IImage` (a resource path such as `/Assets/photo.jpg`); `Stretch` instead of `Aspect`; the wheel and the + / - / 0 keys zoom; the thumbnail and the controls are tab stops |
+| PrevNextNav | Tap a card | The cards are also tab stops (Enter / Space) |
| ChatInput | The send button sends | Also Enter sends and Shift+Enter starts a new line |
| DataTable | Tap a header to sort | Sortable headers are also tab stops (Enter / Space) and keep focus after sorting; a click on a row's checkbox does not raise `RowTapped` |
| Command | Enter picks the highlighted item (the first match) | Up / Down move the highlight and Enter picks it; `Value`, `Text` and the other strings are nullable; children are collected in `Groups` and `Items` |
@@ -1120,6 +1122,24 @@ the current one, default 1), `ShowLabels` (text next to the chevrons) · **Event
---
+### Prev Next Nav
+Previous and next links for the foot of a page: two bordered cards side by side, each a small
+"Previous" or "Next" over the page's name. A side with no label is left empty.
+
+```bash
+shellui-native add prev-next-nav
+```
+
+**Properties:** `PreviousLabel`, `NextLabel`, `PreviousText` ("Previous"), `NextText` ("Next"),
+`IsEnabled` · **Events:** `PreviousClicked`, `NextClicked`
+
+```xml
+
+```
+
+---
+
## Data Display & Feedback
### Badge
@@ -1333,6 +1353,29 @@ with `HeightRequest` instead) · **Events:** `PositionChanged` · **Methods:** `
```
+### Image Viewer
+A thumbnail that opens the image over the page on a dark backdrop, with zoom out, reset, zoom in
+and close controls. A tap on the backdrop or Escape closes it. Give it a size: the thumbnail
+fills it.
+
+```bash
+shellui-native add image-viewer
+```
+
+**Properties:** `Source`, `ThumbnailSource` (a smaller file for the page; `Source` then loads
+only in the viewer), `Description` (for screen readers), `Aspect` (how the thumbnail fills its
+box; AspectFill), `Open` (two-way), `Zoom` (read-only; 0.5 to 4, 1 fits the page) ·
+**Events:** `OpenChanged` · **Methods:** `SetOpen(bool)`, `ZoomIn()`, `ZoomOut()`, `ResetZoom()`
+
+```xml
+
+```
+
+Zooming scales the image around its centre; it does not pan.
+
+---
+
### 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.
diff --git a/docs/COMPONENTS_ROADMAP.md b/docs/COMPONENTS_ROADMAP.md
index 9f95050..d3163cc 100644
--- a/docs/COMPONENTS_ROADMAP.md
+++ b/docs/COMPONENTS_ROADMAP.md
@@ -4,7 +4,7 @@ Prioritized list of components to create for ShellUI Native, aligned with [Shell
Last revised: **2026-10-11** (second prerelease, `0.1.0-alpha.2`).
-**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table, chat, form.
+**Status at a glance:** P0–P6 ✅ done · P7 done except chart · `0.1.0-alpha.2`: 63 families, 94 CLI targets, each for MAUI and Avalonia · since then: button-group, input-group, menubar, command, navigation-menu, data-table, chat, form, image-viewer, prev-next-nav.
---
@@ -296,9 +296,9 @@ resizable, navbar, sidebar, date-range-picker and file-upload on both platforms
**After `0.1.0-alpha.2`, from ShellUI's Blazor set** (both platforms): button-group ✅,
input-group ✅ and menubar ✅ (2026-10-11), then command ✅ (with a command palette) and
navigation-menu ✅, then data-table ✅ (client-side filter, sort, selection and pages;
-2026-10-12), then chat ✅ and form ✅ (2026-10-13; 71 families, 102 CLI targets).
+2026-10-12), then chat ✅ and form ✅, then image-viewer ✅ and prev-next-nav ✅ (2026-10-13;
+73 families, 104 CLI targets).
-**Backlog:** chart (P7); and the ShellUI Blazor targets not yet ported (image-viewer,
-prev-next-nav, …). qr-code waits, like the icon switch, for NuGet package dependencies in the
+**Backlog:** chart (P7). qr-code waits, like the icon switch, for NuGet package dependencies in the
CLI: it needs a QR encoder. Not in the native data-table yet: row actions and server-side
paging.
diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md
index 3e11e51..12ad79d 100644
--- a/docs/DEVELOPMENT_PLAN.md
+++ b/docs/DEVELOPMENT_PLAN.md
@@ -20,8 +20,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni
layout, navigation and pickers on both platforms (PR #14) merged
release 0.1.0-alpha.2 (PR #15), CI action bumps (PR #16), button-group,
input-group and menubar (PR #17), command and navigation-menu (PR #18),
- data-table (PR #19) merged
- └─ feat/chat-form ← chat and form from ShellUI's Blazor set (active)
+ data-table (PR #19), chat and form (PR #20) merged
+ └─ feat/image-viewer-prev-next ← image-viewer and prev-next-nav (active)
└─ feat/winui ← Phase 3 (conditional)
```
@@ -38,8 +38,8 @@ main ← Phase 1 (PRs #1–#5), release 0.1.0-alpha.1 (PR #6), Phase 2 Avaloni
| Branch | Base | Status | Purpose |
|--------|------|--------|---------|
-| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#19 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content |
-| `feat/chat-form` | `main` | **Active** | chat and form for MAUI and Avalonia (105 registry entries, 102 CLI targets, 71 families) |
+| `main` | — | Phase 1, Phase 2 and PR #14 merged; released as `0.1.0-alpha.2`; PRs #17–#20 merged since | 97 registry entries (94 CLI targets, 63 families), each with MAUI and Avalonia content |
+| `feat/image-viewer-prev-next` | `main` | **Active** | image-viewer and prev-next-nav for MAUI and Avalonia (107 registry entries, 104 CLI targets, 73 families) |
---
@@ -536,6 +536,9 @@ Cross-desktop (Windows + macOS + Linux) from one XAML codebase.
- [x] chat and form (`feat/chat-form`): Chat, ChatMessage and ChatInput (the message's name
property is `Sender` on both platforms, since Avalonia controls already have a `Name`); Form
and FormField, where validation stays with the app and the form only reports field errors
+- [x] image-viewer and prev-next-nav (`feat/image-viewer-prev-next`): ImageViewer is a
+ `ShellOverlayHost` whose trigger is its own thumbnail; zoom scales the image, no panning.
+ With these, the Blazor set is ported except the chart family and qr-code
- [ ] 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/examples/Avalonia.Demo/Components/UI/ImageViewer.cs b/examples/Avalonia.Demo/Components/UI/ImageViewer.cs
new file mode 100644
index 0000000..de73b7d
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/ImageViewer.cs
@@ -0,0 +1,285 @@
+using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Image viewer — a rounded-md thumbnail that opens the image over the window on a dark backdrop,
+// with zoom out, reset, zoom in and close controls. The wheel and the + / - / 0 keys zoom too; a
+// click on the backdrop or Escape closes it. Give it a size: the thumbnail fills it.
+//
+// ThumbnailSource shows a smaller file in the page and Source only in the viewer.
+public class ImageViewer : ShellOverlayHost, IShellFocusable
+{
+ public static readonly StyledProperty SourceProperty =
+ AvaloniaProperty.Register(nameof(Source));
+
+ public static readonly StyledProperty ThumbnailSourceProperty =
+ AvaloniaProperty.Register(nameof(ThumbnailSource));
+
+ // What the image shows, for screen readers.
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ // How the thumbnail fills its box.
+ public static readonly StyledProperty StretchProperty =
+ AvaloniaProperty.Register(nameof(Stretch), Stretch.UniformToFill);
+
+ public const double MinZoom = 0.5;
+ public const double MaxZoom = 4;
+
+ private readonly Image _thumbnailImage = new() { Stretch = Stretch.UniformToFill, RenderTransformOrigin = RelativePoint.Center };
+ private readonly Border _thumbnail;
+ private readonly ImageViewerContent _content;
+
+ static ImageViewer()
+ {
+ SourceProperty.Changed.AddClassHandler((v, _) => v.UpdateSources());
+ ThumbnailSourceProperty.Changed.AddClassHandler((v, _) => v.UpdateSources());
+ DescriptionProperty.Changed.AddClassHandler((v, _) => v.UpdateSources());
+ StretchProperty.Changed.AddClassHandler((v, e) => v._thumbnailImage.Stretch = (Stretch)e.NewValue!);
+ }
+
+ public IImage? Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public IImage? ThumbnailSource
+ {
+ get => GetValue(ThumbnailSourceProperty);
+ set => SetValue(ThumbnailSourceProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public Stretch Stretch
+ {
+ get => GetValue(StretchProperty);
+ set => SetValue(StretchProperty, value);
+ }
+
+ // 1 is the size that fits the window.
+ public double Zoom { get; private set; } = 1;
+
+ public ImageViewer()
+ {
+ _thumbnailImage.Transitions = new Transitions
+ {
+ new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() }
+ };
+ _thumbnail = new Border
+ {
+ Child = _thumbnailImage,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ ClipToBounds = true,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ // The thumbnail grows a little under the pointer.
+ _thumbnail.PointerEntered += (_, _) => _thumbnailImage.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse("scale(1.05)");
+ _thumbnail.PointerExited += (_, _) => _thumbnailImage.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse("scale(1)");
+ _thumbnail.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(true);
+ e.Handled = true;
+ };
+
+ _content = new ImageViewerContent(this);
+ Items.Add(_thumbnail);
+ Items.Add(_content);
+ ShellFocus.Ring(this, _thumbnail);
+ OpenChanged += (_, open) =>
+ {
+ if (open) SetZoom(1);
+ };
+ }
+
+ public void ZoomIn() => SetZoom(Zoom + 0.25);
+ public void ZoomOut() => SetZoom(Zoom - 0.25);
+ public void ResetZoom() => SetZoom(1);
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (Open || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ internal void SetZoom(double zoom)
+ {
+ Zoom = Math.Clamp(zoom, MinZoom, MaxZoom);
+ _content.ShowZoom(Zoom);
+ }
+
+ private void UpdateSources()
+ {
+ _thumbnailImage.Source = ThumbnailSource ?? Source;
+ _content.Image.Source = Source;
+ AutomationProperties.SetName(this, string.IsNullOrEmpty(Description) ? "View image" : $"View {Description}");
+ AutomationProperties.SetName(_content.Image, Description ?? string.Empty);
+ }
+}
+
+// The backdrop, image and controls of an open ImageViewer.
+internal sealed class ImageViewerContent : Border, IShellOverlayContent
+{
+ public Image Image { get; } = new()
+ {
+ Stretch = Stretch.Uniform,
+ StretchDirection = StretchDirection.DownOnly,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ RenderTransformOrigin = RelativePoint.Center
+ };
+
+ private readonly ImageViewer _viewer;
+ private readonly Border _zoomOut;
+ private readonly Border _zoomIn;
+ private readonly TextBlock _percent;
+
+ public ImageViewerContent(ImageViewer viewer)
+ {
+ _viewer = viewer;
+ // bg-black/80 in both themes.
+ var backdrop = new Border { Background = new SolidColorBrush(Colors.Black, 0.8) };
+ backdrop.PointerPressed += (_, e) =>
+ {
+ viewer.SetOpen(false);
+ e.Handled = true;
+ };
+
+ Image.Transitions = new Transitions
+ {
+ new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() }
+ };
+ // Clicks on the image must not reach the backdrop behind it.
+ Image.PointerPressed += (_, e) => e.Handled = true;
+
+ _percent = new TextBlock { FontSize = 12, Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _zoomOut = Control(new Icon { Kind = IconName.Minus, Size = 16, Color = Colors.White }, "Zoom out", viewer.ZoomOut);
+ var reset = Control(_percent, "Reset zoom", viewer.ResetZoom);
+ reset.Width = 52;
+ _zoomIn = Control(new Icon { Kind = IconName.Plus, Size = 16, Color = Colors.White }, "Zoom in", viewer.ZoomIn);
+ var close = Control(new Icon { Kind = IconName.X, Size = 16, Color = Colors.White }, "Close", () => viewer.SetOpen(false));
+
+ var controls = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ Margin = new Thickness(16),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Children = { _zoomOut, reset, _zoomIn, close }
+ };
+
+ Child = new Panel { Children = { backdrop, Image, controls } };
+ ClipToBounds = true;
+ // max-h-[90vh] max-w-[90vw]
+ SizeChanged += (_, e) =>
+ {
+ Image.MaxWidth = e.NewSize.Width * 0.9;
+ Image.MaxHeight = e.NewSize.Height * 0.9;
+ };
+ ShowZoom(1);
+ }
+
+ public void ShowZoom(double zoom)
+ {
+ _percent.Text = $"{zoom * 100:0}%";
+ _zoomOut.Opacity = zoom <= ImageViewer.MinZoom ? 0.5 : 1;
+ _zoomIn.Opacity = zoom >= ImageViewer.MaxZoom ? 0.5 : 1;
+ Image.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse(
+ FormattableString.Invariant($"scale({zoom})"));
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ switch (e.Key)
+ {
+ case Key.OemPlus or Key.Add:
+ _viewer.ZoomIn();
+ break;
+ case Key.OemMinus or Key.Subtract:
+ _viewer.ZoomOut();
+ break;
+ case Key.D0 or Key.NumPad0:
+ _viewer.ResetZoom();
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ if (e.Delta.Y > 0) _viewer.ZoomIn();
+ else if (e.Delta.Y < 0) _viewer.ZoomOut();
+ e.Handled = true;
+ }
+
+ // h-9 w-9 rounded-md bg-white/10 text-white hover:bg-white/20; a tab stop (Enter / Space).
+ private static Border Control(Control content, string name, Action action)
+ {
+ content.HorizontalAlignment = HorizontalAlignment.Center;
+ content.VerticalAlignment = VerticalAlignment.Center;
+ var normal = new SolidColorBrush(Colors.White, 0.1);
+ var hover = new SolidColorBrush(Colors.White, 0.2);
+ var control = new Border
+ {
+ Child = content,
+ Width = 36,
+ Height = 36,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = normal,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ AutomationProperties.SetName(control, name);
+ ShellFocus.Ring(control, control);
+ control.PointerEntered += (_, _) => control.Background = hover;
+ control.PointerExited += (_, _) => control.Background = normal;
+ control.PointerPressed += (_, e) => e.Handled = true;
+ control.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ action();
+ e.Handled = true;
+ };
+ control.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ action();
+ e.Handled = true;
+ };
+ return control;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(this, 0, "scale(1)");
+ await ShellMotion.To(this, 1, "scale(1)", 150, new CubicEaseOut());
+ }
+ else
+ {
+ await ShellMotion.To(this, 0, "scale(1)", 120, new CubicEaseIn());
+ }
+ }
+}
diff --git a/examples/Avalonia.Demo/Components/UI/PrevNextNav.cs b/examples/Avalonia.Demo/Components/UI/PrevNextNav.cs
new file mode 100644
index 0000000..2fcd63d
--- /dev/null
+++ b/examples/Avalonia.Demo/Components/UI/PrevNextNav.cs
@@ -0,0 +1,133 @@
+using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+
+namespace AvaloniaDemo.Components.UI;
+
+// Previous / next links for the foot of a page — two rounded-md bordered cards side by side,
+// each a muted "Previous" or "Next" over the page's name. A side with no label is left empty.
+// The cards are tab stops (Enter / Space).
+//
+public class PrevNextNav : Border
+{
+ public static readonly StyledProperty PreviousLabelProperty =
+ AvaloniaProperty.Register(nameof(PreviousLabel));
+
+ public static readonly StyledProperty NextLabelProperty =
+ AvaloniaProperty.Register(nameof(NextLabel));
+
+ // The small text over each label.
+ public static readonly StyledProperty PreviousTextProperty =
+ AvaloniaProperty.Register(nameof(PreviousText), "Previous");
+
+ public static readonly StyledProperty NextTextProperty =
+ AvaloniaProperty.Register(nameof(NextText), "Next");
+
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly TextBlock _previousText;
+ private readonly TextBlock _previousLabel;
+ private readonly TextBlock _nextText;
+ private readonly TextBlock _nextLabel;
+
+ static PrevNextNav()
+ {
+ PreviousLabelProperty.Changed.AddClassHandler((n, _) => n.Update());
+ NextLabelProperty.Changed.AddClassHandler((n, _) => n.Update());
+ PreviousTextProperty.Changed.AddClassHandler((n, _) => n.Update());
+ NextTextProperty.Changed.AddClassHandler((n, _) => n.Update());
+ IsEnabledProperty.Changed.AddClassHandler((n, _) => n.Opacity = n.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? PreviousLabel
+ {
+ get => GetValue(PreviousLabelProperty);
+ set => SetValue(PreviousLabelProperty, value);
+ }
+
+ public string? NextLabel
+ {
+ get => GetValue(NextLabelProperty);
+ set => SetValue(NextLabelProperty, value);
+ }
+
+ public string? PreviousText
+ {
+ get => GetValue(PreviousTextProperty);
+ set => SetValue(PreviousTextProperty, value);
+ }
+
+ public string? NextText
+ {
+ get => GetValue(NextTextProperty);
+ set => SetValue(NextTextProperty, value);
+ }
+
+ public event EventHandler? PreviousClicked;
+ public event EventHandler? NextClicked;
+
+ public PrevNextNav()
+ {
+ (_previous, _previousText, _previousLabel) = Card(TextAlignment.Left, () => PreviousClicked?.Invoke(this, EventArgs.Empty));
+ (_next, _nextText, _nextLabel) = Card(TextAlignment.Right, () => NextClicked?.Invoke(this, EventArgs.Empty));
+ Grid.SetColumn(_next, 1);
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions("*,*"), ColumnSpacing = 16, Children = { _previous, _next } };
+ Update();
+ }
+
+ // rounded-md border p-4, hover:bg-accent: text-sm muted over a font-medium label.
+ private (Border Card, TextBlock Text, TextBlock Label) Card(TextAlignment alignment, Action clicked)
+ {
+ var text = new TextBlock { FontSize = 14, TextAlignment = alignment };
+ text.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ var label = new TextBlock { FontSize = 16, FontWeight = FontWeight.Medium, TextAlignment = alignment, TextTrimming = TextTrimming.CharacterEllipsis };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ var card = new Border
+ {
+ Child = new StackPanel { Spacing = 2, Children = { text, label } },
+ Padding = new Thickness(16),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ // Transparent, not null, so the whole card takes the pointer.
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ card.Token(BorderBrushProperty, ShellToken.Border);
+ ShellFocus.Ring(card, card);
+ card.PointerEntered += (_, _) => { if (IsEffectivelyEnabled) card.Token(BackgroundProperty, ShellToken.Accent); };
+ card.PointerExited += (_, _) =>
+ {
+ card.ClearToken(BackgroundProperty);
+ card.Background = Brushes.Transparent;
+ };
+ card.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ clicked();
+ e.Handled = true;
+ };
+ card.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return;
+ clicked();
+ e.Handled = true;
+ };
+ return (card, text, label);
+ }
+
+ private void Update()
+ {
+ _previousText.Text = PreviousText ?? string.Empty;
+ _previousLabel.Text = PreviousLabel ?? string.Empty;
+ _previous.IsVisible = !string.IsNullOrEmpty(PreviousLabel);
+ AutomationProperties.SetName(_previous, $"{PreviousText}: {PreviousLabel}");
+ _nextText.Text = NextText ?? string.Empty;
+ _nextLabel.Text = NextLabel ?? string.Empty;
+ _next.IsVisible = !string.IsNullOrEmpty(NextLabel);
+ AutomationProperties.SetName(_next, $"{NextText}: {NextLabel}");
+ }
+}
diff --git a/examples/Avalonia.Demo/MainWindow.axaml b/examples/Avalonia.Demo/MainWindow.axaml
index b6b4568..898b46c 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml
+++ b/examples/Avalonia.Demo/MainWindow.axaml
@@ -718,6 +718,16 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/Avalonia.Demo/MainWindow.axaml.cs b/examples/Avalonia.Demo/MainWindow.axaml.cs
index 6b9cc50..1a1d4c8 100644
--- a/examples/Avalonia.Demo/MainWindow.axaml.cs
+++ b/examples/Avalonia.Demo/MainWindow.axaml.cs
@@ -123,6 +123,17 @@ public MainWindow()
DemoPassword.HasError = DemoPasswordField.HasError;
ChatFormChoice.Text = DemoForm.IsValid ? "Form: valid." : $"Form: {DemoForm.FormFields().Count(f => f.HasError)} error(s), first is {DemoForm.FirstInvalid?.Label}.";
};
+ var docsPages = new[] { "Installation", "Quick start", "Theming", "Components" };
+ var docsPage = 1;
+ void ShowDocsPage(int page)
+ {
+ docsPage = Math.Clamp(page, 0, docsPages.Length - 1);
+ DemoPrevNext.PreviousLabel = docsPage > 0 ? docsPages[docsPage - 1] : null;
+ DemoPrevNext.NextLabel = docsPage < docsPages.Length - 1 ? docsPages[docsPage + 1] : null;
+ PrevNextChoice.Text = $"Page: {docsPages[docsPage]}.";
+ }
+ DemoPrevNext.PreviousClicked += (_, _) => ShowDocsPage(docsPage - 1);
+ DemoPrevNext.NextClicked += (_, _) => ShowDocsPage(docsPage + 1);
DemoNavbar.SelectedValueChanged += (_, value) => LayoutChoice.Text = $"Navbar: {value}.";
DemoSidebar.SelectedValueChanged += (_, value) =>
{
diff --git a/examples/MAUI.Demo/Components/UI/ImageViewer.cs b/examples/MAUI.Demo/Components/UI/ImageViewer.cs
new file mode 100644
index 0000000..f5111a1
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/ImageViewer.cs
@@ -0,0 +1,227 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Image viewer — a rounded-md thumbnail that opens the image over the page on a dark backdrop,
+// with zoom out, reset, zoom in and close controls. Pinching zooms too; a tap on the backdrop
+// or Escape closes it. Give it a size: the thumbnail fills it.
+//
+// ThumbnailSource shows a smaller file in the page and loads Source only in the viewer.
+public partial class ImageViewer : ShellOverlayHost
+{
+ public static readonly BindableProperty SourceProperty =
+ BindableProperty.Create(nameof(Source), typeof(ImageSource), typeof(ImageViewer), null,
+ propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources());
+
+ public static readonly BindableProperty ThumbnailSourceProperty =
+ BindableProperty.Create(nameof(ThumbnailSource), typeof(ImageSource), typeof(ImageViewer), null,
+ propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources());
+
+ // What the image shows, for screen readers.
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(ImageViewer), string.Empty,
+ propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources());
+
+ // How the thumbnail fills its box.
+ public static readonly BindableProperty AspectProperty =
+ BindableProperty.Create(nameof(Aspect), typeof(Aspect), typeof(ImageViewer), Aspect.AspectFill,
+ propertyChanged: (b, o, n) => ((ImageViewer)b)._thumbnail.Image.Aspect = (Aspect)n);
+
+ public ImageSource? Source
+ {
+ get => (ImageSource?)GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public ImageSource? ThumbnailSource
+ {
+ get => (ImageSource?)GetValue(ThumbnailSourceProperty);
+ set => SetValue(ThumbnailSourceProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public Aspect Aspect
+ {
+ get => (Aspect)GetValue(AspectProperty);
+ set => SetValue(AspectProperty, value);
+ }
+
+ public const double MinZoom = 0.5;
+ public const double MaxZoom = 4;
+
+ // 1 is the size that fits the page.
+ public double Zoom { get; private set; } = 1;
+
+ private readonly ImageViewerThumbnail _thumbnail;
+ private readonly ImageViewerContent _content;
+
+ public ImageViewer()
+ {
+ _thumbnail = new ImageViewerThumbnail(() => SetOpen(true));
+ _content = new ImageViewerContent(this);
+ Children.Add(_thumbnail);
+ Children.Add(_content);
+ OpenChanged += (_, open) =>
+ {
+ if (open) SetZoom(1, animate: false);
+ };
+ }
+
+ protected override bool IsTrigger(Element child) => child is ImageViewerThumbnail;
+
+ public void ZoomIn() => SetZoom(Zoom + 0.25);
+ public void ZoomOut() => SetZoom(Zoom - 0.25);
+ public void ResetZoom() => SetZoom(1);
+
+ internal void SetZoom(double zoom, bool animate = true)
+ {
+ Zoom = Math.Clamp(zoom, MinZoom, MaxZoom);
+ _content.ShowZoom(Zoom, animate);
+ }
+
+ private void UpdateSources()
+ {
+ _thumbnail.Image.Source = ThumbnailSource ?? Source;
+ _content.Image.Source = Source;
+ SemanticProperties.SetDescription(_thumbnail, string.IsNullOrEmpty(Description) ? "View image" : $"View {Description}");
+ SemanticProperties.SetDescription(_content.Image, Description ?? string.Empty);
+ }
+}
+
+// The thumbnail of an ImageViewer; it grows a little under the pointer.
+internal sealed partial class ImageViewerThumbnail : ContentView
+{
+ public Image Image { get; } = new() { Aspect = Aspect.AspectFill };
+
+ public ImageViewerThumbnail(Action open)
+ {
+ var frame = new Border
+ {
+ Content = Image,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => _ = Image.ScaleToAsync(1.05, 200, Easing.CubicOut);
+ pointer.PointerExited += (_, _) => _ = Image.ScaleToAsync(1, 200, Easing.CubicOut);
+ frame.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); open(); };
+ frame.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, open);
+ Content = frame;
+ }
+}
+
+// The backdrop, image and controls of an open ImageViewer.
+internal sealed partial class ImageViewerContent : ContentView, IShellOverlayContent
+{
+ public Image Image { get; } = new() { Aspect = Aspect.AspectFit, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+
+ private readonly ImageViewer _viewer;
+ private readonly BoxView _backdrop;
+ private readonly Border _zoomOut;
+ private readonly Border _zoomIn;
+ private readonly Label _percent;
+ private double _pinchStart = 1;
+
+ public ImageViewerContent(ImageViewer viewer)
+ {
+ _viewer = viewer;
+ // bg-black/80 in both themes.
+ _backdrop = new BoxView { Color = Color.FromRgba(0, 0, 0, 0.8) };
+ var tapBackdrop = new TapGestureRecognizer();
+ tapBackdrop.Tapped += (_, _) => viewer.SetOpen(false);
+ _backdrop.GestureRecognizers.Add(tapBackdrop);
+
+ // Swallow taps on the image so they don't fall through to the backdrop.
+ Image.GestureRecognizers.Add(new TapGestureRecognizer());
+ var pinch = new PinchGestureRecognizer();
+ pinch.PinchUpdated += (_, e) =>
+ {
+ if (e.Status == GestureStatus.Started) _pinchStart = viewer.Zoom;
+ else if (e.Status == GestureStatus.Running) viewer.SetZoom(_pinchStart * e.Scale, animate: false);
+ };
+ Image.GestureRecognizers.Add(pinch);
+
+ _percent = new Label { FontSize = 12, TextColor = Colors.White, VerticalTextAlignment = TextAlignment.Center, HorizontalTextAlignment = TextAlignment.Center };
+ _zoomOut = Control(new Icon { Name = IconName.Minus, Size = 16, Color = Colors.White }, "Zoom out", viewer.ZoomOut);
+ _zoomIn = Control(new Icon { Name = IconName.Plus, Size = 16, Color = Colors.White }, "Zoom in", viewer.ZoomIn);
+ var reset = Control(_percent, "Reset zoom", viewer.ResetZoom);
+ reset.WidthRequest = 52;
+ var close = Control(new Icon { Name = IconName.X, Size = 16, Color = Colors.White }, "Close", () => viewer.SetOpen(false));
+
+ var controls = new HorizontalStackLayout
+ {
+ Spacing = 8,
+ Margin = new Thickness(16),
+ HorizontalOptions = LayoutOptions.End,
+ VerticalOptions = LayoutOptions.Start,
+ Children = { _zoomOut, reset, _zoomIn, close }
+ };
+
+ var root = new Grid { Children = { _backdrop, Image, controls } };
+ ShellPortal.EdgeToEdge(this, root);
+ // max-h-[90vh] max-w-[90vw]
+ root.SizeChanged += (_, _) =>
+ {
+ Image.MaximumWidthRequest = root.Width * 0.9;
+ Image.MaximumHeightRequest = root.Height * 0.9;
+ };
+ Content = root;
+ ShowZoom(1, animate: false);
+ }
+
+ public void ShowZoom(double zoom, bool animate)
+ {
+ _percent.Text = $"{zoom * 100:0}%";
+ _zoomOut.Opacity = zoom <= ImageViewer.MinZoom ? 0.5 : 1;
+ _zoomIn.Opacity = zoom >= ImageViewer.MaxZoom ? 0.5 : 1;
+ Image.AbortAnimation("ScaleTo");
+ if (animate) _ = Image.ScaleToAsync(zoom, 200, Easing.CubicOut);
+ else Image.Scale = zoom;
+ }
+
+ // h-9 w-9 rounded-md bg-white/10 text-white hover:bg-white/20
+ private static Border Control(View content, string description, Action action)
+ {
+ content.HorizontalOptions = LayoutOptions.Center;
+ content.VerticalOptions = LayoutOptions.Center;
+ var control = new Border
+ {
+ Content = content,
+ WidthRequest = 36,
+ HeightRequest = 36,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Color.FromRgba(255, 255, 255, 0.1)
+ };
+ SemanticProperties.SetDescription(control, description);
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => control.BackgroundColor = Color.FromRgba(255, 255, 255, 0.2);
+ pointer.PointerExited += (_, _) => control.BackgroundColor = Color.FromRgba(255, 255, 255, 0.1);
+ control.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => action();
+ control.GestureRecognizers.Add(tap);
+ return control;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ Opacity = 0;
+ await this.FadeToAsync(1, 150, Easing.CubicOut);
+ }
+ else
+ {
+ await this.FadeToAsync(0, 120, Easing.CubicIn);
+ }
+ }
+}
diff --git a/examples/MAUI.Demo/Components/UI/PrevNextNav.cs b/examples/MAUI.Demo/Components/UI/PrevNextNav.cs
new file mode 100644
index 0000000..09c2eee
--- /dev/null
+++ b/examples/MAUI.Demo/Components/UI/PrevNextNav.cs
@@ -0,0 +1,123 @@
+using Microsoft.Maui.Controls.Shapes;
+
+namespace MAUI.Demo.Components.UI;
+
+// Previous / next links for the foot of a page — two rounded-md bordered cards side by side,
+// each a muted "Previous" or "Next" over the page's name. A side with no label is left empty.
+//
+public partial class PrevNextNav : ContentView
+{
+ public static readonly BindableProperty PreviousLabelProperty =
+ BindableProperty.Create(nameof(PreviousLabel), typeof(string), typeof(PrevNextNav), string.Empty,
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ public static readonly BindableProperty NextLabelProperty =
+ BindableProperty.Create(nameof(NextLabel), typeof(string), typeof(PrevNextNav), string.Empty,
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ // The small text over each label.
+ public static readonly BindableProperty PreviousTextProperty =
+ BindableProperty.Create(nameof(PreviousText), typeof(string), typeof(PrevNextNav), "Previous",
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ public static readonly BindableProperty NextTextProperty =
+ BindableProperty.Create(nameof(NextText), typeof(string), typeof(PrevNextNav), "Next",
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ public string PreviousLabel
+ {
+ get => (string)GetValue(PreviousLabelProperty);
+ set => SetValue(PreviousLabelProperty, value);
+ }
+
+ public string NextLabel
+ {
+ get => (string)GetValue(NextLabelProperty);
+ set => SetValue(NextLabelProperty, value);
+ }
+
+ public string PreviousText
+ {
+ get => (string)GetValue(PreviousTextProperty);
+ set => SetValue(PreviousTextProperty, value);
+ }
+
+ public string NextText
+ {
+ get => (string)GetValue(NextTextProperty);
+ set => SetValue(NextTextProperty, value);
+ }
+
+ public event EventHandler? PreviousClicked;
+ public event EventHandler? NextClicked;
+
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly Label _previousText;
+ private readonly Label _previousLabel;
+ private readonly Label _nextText;
+ private readonly Label _nextLabel;
+
+ public PrevNextNav()
+ {
+ (_previous, _previousText, _previousLabel) = Card(TextAlignment.Start, () => PreviousClicked?.Invoke(this, EventArgs.Empty));
+ (_next, _nextText, _nextLabel) = Card(TextAlignment.End, () => NextClicked?.Invoke(this, EventArgs.Empty));
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 16,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) }
+ };
+ grid.Add(_previous, 0, 0);
+ grid.Add(_next, 1, 0);
+ Content = grid;
+ Update();
+ }
+
+ // rounded-md border p-4, hover:bg-accent: text-sm muted over a font-medium label.
+ private (Border Card, Label Text, Label Label) Card(TextAlignment alignment, Action clicked)
+ {
+ var text = new Label { FontSize = 14, HorizontalTextAlignment = alignment };
+ text.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ var label = new Label { FontSize = 16, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = alignment, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ var card = new Border
+ {
+ Content = new VerticalStackLayout { Spacing = 2, Children = { text, label } },
+ Padding = new Thickness(16),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ card.Token(Border.StrokeProperty, ShellToken.Border);
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) card.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); };
+ pointer.PointerExited += (_, _) => { card.ClearValue(VisualElement.BackgroundColorProperty); card.BackgroundColor = Colors.Transparent; };
+ card.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) { ShellFocus.FocusPressed(card); clicked(); } };
+ card.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(card, () => { if (IsEnabled) clicked(); });
+ return (card, text, label);
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _previousText.Text = PreviousText ?? string.Empty;
+ _previousLabel.Text = PreviousLabel ?? string.Empty;
+ _previous.IsVisible = !string.IsNullOrEmpty(PreviousLabel);
+ SemanticProperties.SetDescription(_previous, $"{PreviousText}: {PreviousLabel}");
+ _nextText.Text = NextText ?? string.Empty;
+ _nextLabel.Text = NextLabel ?? string.Empty;
+ _next.IsVisible = !string.IsNullOrEmpty(NextLabel);
+ SemanticProperties.SetDescription(_next, $"{NextText}: {NextLabel}");
+ }
+}
diff --git a/examples/MAUI.Demo/MainPage.xaml b/examples/MAUI.Demo/MainPage.xaml
index 6d854f0..038613d 100644
--- a/examples/MAUI.Demo/MainPage.xaml
+++ b/examples/MAUI.Demo/MainPage.xaml
@@ -508,6 +508,11 @@
+
+
+
+
@@ -845,6 +850,15 @@
+
+
+
+
+
+
+
+
diff --git a/examples/MAUI.Demo/MainPage.xaml.cs b/examples/MAUI.Demo/MainPage.xaml.cs
index d8c9574..3259987 100644
--- a/examples/MAUI.Demo/MainPage.xaml.cs
+++ b/examples/MAUI.Demo/MainPage.xaml.cs
@@ -148,6 +148,18 @@ private void OnMultiSelectChanged(object? sender, IReadOnlyList values)
private void OnCarouselChanged(object? sender, int position) => Report($"Carousel: slide {position + 1}");
private TableRow? _selectedRow;
+ private static readonly string[] DocsPages = { "Installation", "Quick start", "Theming", "Components" };
+ private int _docsPage = 1;
+ private void OnPrevious(object? sender, EventArgs e) => ShowDocsPage(_docsPage - 1);
+ private void OnNext(object? sender, EventArgs e) => ShowDocsPage(_docsPage + 1);
+ private void ShowDocsPage(int page)
+ {
+ _docsPage = Math.Clamp(page, 0, DocsPages.Length - 1);
+ PrevNextDemo.PreviousLabel = _docsPage > 0 ? DocsPages[_docsPage - 1] : string.Empty;
+ PrevNextDemo.NextLabel = _docsPage < DocsPages.Length - 1 ? DocsPages[_docsPage + 1] : string.Empty;
+ Report($"Prev next: page {DocsPages[_docsPage]}");
+ }
+
private void OnFormSubmit(object? sender, EventArgs e)
{
FormEmailField.Error = (FormEmail.Text ?? string.Empty).Contains('@') ? string.Empty : "Enter a valid email address.";
diff --git a/scripts/sync-templates.py b/scripts/sync-templates.py
index 8845075..8344487 100644
--- a/scripts/sync-templates.py
+++ b/scripts/sync-templates.py
@@ -106,6 +106,10 @@
["chat", "message", "conversation", "assistant"]),
"Form": ("form", "Form", "Fields with a label, description and error message", "Form",
["form", "field", "validation", "label"]),
+ "ImageViewer": ("image-viewer", "Image Viewer", "Thumbnail that opens the image over the page, with zoom", "Media",
+ ["image", "viewer", "lightbox", "zoom", "gallery"]),
+ "PrevNextNav": ("prev-next-nav", "Prev Next Nav", "Previous and next page links for the foot of a page", "Navigation",
+ ["previous", "next", "navigation", "pager", "docs"]),
"DateRangePicker": ("date-range-picker", "Date Range Picker", "Picks a start and an end date from one calendar", "Form",
["date", "range", "picker", "calendar"]),
"FileUpload": ("file-upload", "File Upload", "Drop zone that opens the file picker and lists the picked files", "Form",
diff --git a/src/ShellUI.Native.Templates/ComponentRegistry.cs b/src/ShellUI.Native.Templates/ComponentRegistry.cs
index 9c8629f..a1a083a 100644
--- a/src/ShellUI.Native.Templates/ComponentRegistry.cs
+++ b/src/ShellUI.Native.Templates/ComponentRegistry.cs
@@ -134,7 +134,9 @@ public static class ComponentRegistry
{ "navigation-menu", (NavigationMenuTemplate.Metadata, NavigationMenuTemplate.Contents) },
{ "data-table", (DataTableTemplate.Metadata, DataTableTemplate.Contents) },
{ "chat", (ChatTemplate.Metadata, ChatTemplate.Contents) },
- { "form", (FormTemplate.Metadata, FormTemplate.Contents) }
+ { "form", (FormTemplate.Metadata, FormTemplate.Contents) },
+ { "image-viewer", (ImageViewerTemplate.Metadata, ImageViewerTemplate.Contents) },
+ { "prev-next-nav", (PrevNextNavTemplate.Metadata, PrevNextNavTemplate.Contents) }
};
// Metadata-only view for callers that don't need the per-platform payload.
diff --git a/src/ShellUI.Native.Templates/Templates/ImageViewerTemplate.cs b/src/ShellUI.Native.Templates/Templates/ImageViewerTemplate.cs
new file mode 100644
index 0000000..0e57fa6
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/ImageViewerTemplate.cs
@@ -0,0 +1,535 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class ImageViewerTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "image-viewer",
+ DisplayName = "Image Viewer",
+ Description = "Thumbnail that opens the image over the page, with zoom",
+ Category = ComponentCategory.Media,
+ FilePath = "ImageViewer.cs",
+ Dependencies = new List { "shell", "icon" },
+ Tags = new List { "image", "viewer", "lightbox", "zoom", "gallery" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Image viewer — a rounded-md thumbnail that opens the image over the page on a dark backdrop,
+// with zoom out, reset, zoom in and close controls. Pinching zooms too; a tap on the backdrop
+// or Escape closes it. Give it a size: the thumbnail fills it.
+//
+// ThumbnailSource shows a smaller file in the page and loads Source only in the viewer.
+public partial class ImageViewer : ShellOverlayHost
+{
+ public static readonly BindableProperty SourceProperty =
+ BindableProperty.Create(nameof(Source), typeof(ImageSource), typeof(ImageViewer), null,
+ propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources());
+
+ public static readonly BindableProperty ThumbnailSourceProperty =
+ BindableProperty.Create(nameof(ThumbnailSource), typeof(ImageSource), typeof(ImageViewer), null,
+ propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources());
+
+ // What the image shows, for screen readers.
+ public static readonly BindableProperty DescriptionProperty =
+ BindableProperty.Create(nameof(Description), typeof(string), typeof(ImageViewer), string.Empty,
+ propertyChanged: (b, o, n) => ((ImageViewer)b).UpdateSources());
+
+ // How the thumbnail fills its box.
+ public static readonly BindableProperty AspectProperty =
+ BindableProperty.Create(nameof(Aspect), typeof(Aspect), typeof(ImageViewer), Aspect.AspectFill,
+ propertyChanged: (b, o, n) => ((ImageViewer)b)._thumbnail.Image.Aspect = (Aspect)n);
+
+ public ImageSource? Source
+ {
+ get => (ImageSource?)GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public ImageSource? ThumbnailSource
+ {
+ get => (ImageSource?)GetValue(ThumbnailSourceProperty);
+ set => SetValue(ThumbnailSourceProperty, value);
+ }
+
+ public string Description
+ {
+ get => (string)GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public Aspect Aspect
+ {
+ get => (Aspect)GetValue(AspectProperty);
+ set => SetValue(AspectProperty, value);
+ }
+
+ public const double MinZoom = 0.5;
+ public const double MaxZoom = 4;
+
+ // 1 is the size that fits the page.
+ public double Zoom { get; private set; } = 1;
+
+ private readonly ImageViewerThumbnail _thumbnail;
+ private readonly ImageViewerContent _content;
+
+ public ImageViewer()
+ {
+ _thumbnail = new ImageViewerThumbnail(() => SetOpen(true));
+ _content = new ImageViewerContent(this);
+ Children.Add(_thumbnail);
+ Children.Add(_content);
+ OpenChanged += (_, open) =>
+ {
+ if (open) SetZoom(1, animate: false);
+ };
+ }
+
+ protected override bool IsTrigger(Element child) => child is ImageViewerThumbnail;
+
+ public void ZoomIn() => SetZoom(Zoom + 0.25);
+ public void ZoomOut() => SetZoom(Zoom - 0.25);
+ public void ResetZoom() => SetZoom(1);
+
+ internal void SetZoom(double zoom, bool animate = true)
+ {
+ Zoom = Math.Clamp(zoom, MinZoom, MaxZoom);
+ _content.ShowZoom(Zoom, animate);
+ }
+
+ private void UpdateSources()
+ {
+ _thumbnail.Image.Source = ThumbnailSource ?? Source;
+ _content.Image.Source = Source;
+ SemanticProperties.SetDescription(_thumbnail, string.IsNullOrEmpty(Description) ? ""View image"" : $""View {Description}"");
+ SemanticProperties.SetDescription(_content.Image, Description ?? string.Empty);
+ }
+}
+
+// The thumbnail of an ImageViewer; it grows a little under the pointer.
+internal sealed partial class ImageViewerThumbnail : ContentView
+{
+ public Image Image { get; } = new() { Aspect = Aspect.AspectFill };
+
+ public ImageViewerThumbnail(Action open)
+ {
+ var frame = new Border
+ {
+ Content = Image,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd }
+ };
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => _ = Image.ScaleToAsync(1.05, 200, Easing.CubicOut);
+ pointer.PointerExited += (_, _) => _ = Image.ScaleToAsync(1, 200, Easing.CubicOut);
+ frame.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { ShellFocus.FocusPressed(this); open(); };
+ frame.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(this, open);
+ Content = frame;
+ }
+}
+
+// The backdrop, image and controls of an open ImageViewer.
+internal sealed partial class ImageViewerContent : ContentView, IShellOverlayContent
+{
+ public Image Image { get; } = new() { Aspect = Aspect.AspectFit, HorizontalOptions = LayoutOptions.Center, VerticalOptions = LayoutOptions.Center };
+
+ private readonly ImageViewer _viewer;
+ private readonly BoxView _backdrop;
+ private readonly Border _zoomOut;
+ private readonly Border _zoomIn;
+ private readonly Label _percent;
+ private double _pinchStart = 1;
+
+ public ImageViewerContent(ImageViewer viewer)
+ {
+ _viewer = viewer;
+ // bg-black/80 in both themes.
+ _backdrop = new BoxView { Color = Color.FromRgba(0, 0, 0, 0.8) };
+ var tapBackdrop = new TapGestureRecognizer();
+ tapBackdrop.Tapped += (_, _) => viewer.SetOpen(false);
+ _backdrop.GestureRecognizers.Add(tapBackdrop);
+
+ // Swallow taps on the image so they don't fall through to the backdrop.
+ Image.GestureRecognizers.Add(new TapGestureRecognizer());
+ var pinch = new PinchGestureRecognizer();
+ pinch.PinchUpdated += (_, e) =>
+ {
+ if (e.Status == GestureStatus.Started) _pinchStart = viewer.Zoom;
+ else if (e.Status == GestureStatus.Running) viewer.SetZoom(_pinchStart * e.Scale, animate: false);
+ };
+ Image.GestureRecognizers.Add(pinch);
+
+ _percent = new Label { FontSize = 12, TextColor = Colors.White, VerticalTextAlignment = TextAlignment.Center, HorizontalTextAlignment = TextAlignment.Center };
+ _zoomOut = Control(new Icon { Name = IconName.Minus, Size = 16, Color = Colors.White }, ""Zoom out"", viewer.ZoomOut);
+ _zoomIn = Control(new Icon { Name = IconName.Plus, Size = 16, Color = Colors.White }, ""Zoom in"", viewer.ZoomIn);
+ var reset = Control(_percent, ""Reset zoom"", viewer.ResetZoom);
+ reset.WidthRequest = 52;
+ var close = Control(new Icon { Name = IconName.X, Size = 16, Color = Colors.White }, ""Close"", () => viewer.SetOpen(false));
+
+ var controls = new HorizontalStackLayout
+ {
+ Spacing = 8,
+ Margin = new Thickness(16),
+ HorizontalOptions = LayoutOptions.End,
+ VerticalOptions = LayoutOptions.Start,
+ Children = { _zoomOut, reset, _zoomIn, close }
+ };
+
+ var root = new Grid { Children = { _backdrop, Image, controls } };
+ ShellPortal.EdgeToEdge(this, root);
+ // max-h-[90vh] max-w-[90vw]
+ root.SizeChanged += (_, _) =>
+ {
+ Image.MaximumWidthRequest = root.Width * 0.9;
+ Image.MaximumHeightRequest = root.Height * 0.9;
+ };
+ Content = root;
+ ShowZoom(1, animate: false);
+ }
+
+ public void ShowZoom(double zoom, bool animate)
+ {
+ _percent.Text = $""{zoom * 100:0}%"";
+ _zoomOut.Opacity = zoom <= ImageViewer.MinZoom ? 0.5 : 1;
+ _zoomIn.Opacity = zoom >= ImageViewer.MaxZoom ? 0.5 : 1;
+ Image.AbortAnimation(""ScaleTo"");
+ if (animate) _ = Image.ScaleToAsync(zoom, 200, Easing.CubicOut);
+ else Image.Scale = zoom;
+ }
+
+ // h-9 w-9 rounded-md bg-white/10 text-white hover:bg-white/20
+ private static Border Control(View content, string description, Action action)
+ {
+ content.HorizontalOptions = LayoutOptions.Center;
+ content.VerticalOptions = LayoutOptions.Center;
+ var control = new Border
+ {
+ Content = content,
+ WidthRequest = 36,
+ HeightRequest = 36,
+ StrokeThickness = 0,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Color.FromRgba(255, 255, 255, 0.1)
+ };
+ SemanticProperties.SetDescription(control, description);
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => control.BackgroundColor = Color.FromRgba(255, 255, 255, 0.2);
+ pointer.PointerExited += (_, _) => control.BackgroundColor = Color.FromRgba(255, 255, 255, 0.1);
+ control.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => action();
+ control.GestureRecognizers.Add(tap);
+ return control;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ Opacity = 0;
+ await this.FadeToAsync(1, 150, Easing.CubicOut);
+ }
+ else
+ {
+ await this.FadeToAsync(0, 120, Easing.CubicIn);
+ }
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Animation;
+using Avalonia.Animation.Easings;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Image viewer — a rounded-md thumbnail that opens the image over the window on a dark backdrop,
+// with zoom out, reset, zoom in and close controls. The wheel and the + / - / 0 keys zoom too; a
+// click on the backdrop or Escape closes it. Give it a size: the thumbnail fills it.
+//
+// ThumbnailSource shows a smaller file in the page and Source only in the viewer.
+public class ImageViewer : ShellOverlayHost, IShellFocusable
+{
+ public static readonly StyledProperty SourceProperty =
+ AvaloniaProperty.Register(nameof(Source));
+
+ public static readonly StyledProperty ThumbnailSourceProperty =
+ AvaloniaProperty.Register(nameof(ThumbnailSource));
+
+ // What the image shows, for screen readers.
+ public static readonly StyledProperty DescriptionProperty =
+ AvaloniaProperty.Register(nameof(Description));
+
+ // How the thumbnail fills its box.
+ public static readonly StyledProperty StretchProperty =
+ AvaloniaProperty.Register(nameof(Stretch), Stretch.UniformToFill);
+
+ public const double MinZoom = 0.5;
+ public const double MaxZoom = 4;
+
+ private readonly Image _thumbnailImage = new() { Stretch = Stretch.UniformToFill, RenderTransformOrigin = RelativePoint.Center };
+ private readonly Border _thumbnail;
+ private readonly ImageViewerContent _content;
+
+ static ImageViewer()
+ {
+ SourceProperty.Changed.AddClassHandler((v, _) => v.UpdateSources());
+ ThumbnailSourceProperty.Changed.AddClassHandler((v, _) => v.UpdateSources());
+ DescriptionProperty.Changed.AddClassHandler((v, _) => v.UpdateSources());
+ StretchProperty.Changed.AddClassHandler((v, e) => v._thumbnailImage.Stretch = (Stretch)e.NewValue!);
+ }
+
+ public IImage? Source
+ {
+ get => GetValue(SourceProperty);
+ set => SetValue(SourceProperty, value);
+ }
+
+ public IImage? ThumbnailSource
+ {
+ get => GetValue(ThumbnailSourceProperty);
+ set => SetValue(ThumbnailSourceProperty, value);
+ }
+
+ public string? Description
+ {
+ get => GetValue(DescriptionProperty);
+ set => SetValue(DescriptionProperty, value);
+ }
+
+ public Stretch Stretch
+ {
+ get => GetValue(StretchProperty);
+ set => SetValue(StretchProperty, value);
+ }
+
+ // 1 is the size that fits the window.
+ public double Zoom { get; private set; } = 1;
+
+ public ImageViewer()
+ {
+ _thumbnailImage.Transitions = new Transitions
+ {
+ new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() }
+ };
+ _thumbnail = new Border
+ {
+ Child = _thumbnailImage,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ ClipToBounds = true,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ // The thumbnail grows a little under the pointer.
+ _thumbnail.PointerEntered += (_, _) => _thumbnailImage.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse(""scale(1.05)"");
+ _thumbnail.PointerExited += (_, _) => _thumbnailImage.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse(""scale(1)"");
+ _thumbnail.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ SetOpen(true);
+ e.Handled = true;
+ };
+
+ _content = new ImageViewerContent(this);
+ Items.Add(_thumbnail);
+ Items.Add(_content);
+ ShellFocus.Ring(this, _thumbnail);
+ OpenChanged += (_, open) =>
+ {
+ if (open) SetZoom(1);
+ };
+ }
+
+ public void ZoomIn() => SetZoom(Zoom + 0.25);
+ public void ZoomOut() => SetZoom(Zoom - 0.25);
+ public void ResetZoom() => SetZoom(1);
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if (Open || !ReferenceEquals(e.Source, this) || e.Key is not (Key.Enter or Key.Space)) return;
+ SetOpen(true);
+ e.Handled = true;
+ }
+
+ internal void SetZoom(double zoom)
+ {
+ Zoom = Math.Clamp(zoom, MinZoom, MaxZoom);
+ _content.ShowZoom(Zoom);
+ }
+
+ private void UpdateSources()
+ {
+ _thumbnailImage.Source = ThumbnailSource ?? Source;
+ _content.Image.Source = Source;
+ AutomationProperties.SetName(this, string.IsNullOrEmpty(Description) ? ""View image"" : $""View {Description}"");
+ AutomationProperties.SetName(_content.Image, Description ?? string.Empty);
+ }
+}
+
+// The backdrop, image and controls of an open ImageViewer.
+internal sealed class ImageViewerContent : Border, IShellOverlayContent
+{
+ public Image Image { get; } = new()
+ {
+ Stretch = Stretch.Uniform,
+ StretchDirection = StretchDirection.DownOnly,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ VerticalAlignment = VerticalAlignment.Center,
+ RenderTransformOrigin = RelativePoint.Center
+ };
+
+ private readonly ImageViewer _viewer;
+ private readonly Border _zoomOut;
+ private readonly Border _zoomIn;
+ private readonly TextBlock _percent;
+
+ public ImageViewerContent(ImageViewer viewer)
+ {
+ _viewer = viewer;
+ // bg-black/80 in both themes.
+ var backdrop = new Border { Background = new SolidColorBrush(Colors.Black, 0.8) };
+ backdrop.PointerPressed += (_, e) =>
+ {
+ viewer.SetOpen(false);
+ e.Handled = true;
+ };
+
+ Image.Transitions = new Transitions
+ {
+ new TransformOperationsTransition { Property = RenderTransformProperty, Duration = TimeSpan.FromMilliseconds(200), Easing = new CubicEaseOut() }
+ };
+ // Clicks on the image must not reach the backdrop behind it.
+ Image.PointerPressed += (_, e) => e.Handled = true;
+
+ _percent = new TextBlock { FontSize = 12, Foreground = Brushes.White, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center };
+ _zoomOut = Control(new Icon { Kind = IconName.Minus, Size = 16, Color = Colors.White }, ""Zoom out"", viewer.ZoomOut);
+ var reset = Control(_percent, ""Reset zoom"", viewer.ResetZoom);
+ reset.Width = 52;
+ _zoomIn = Control(new Icon { Kind = IconName.Plus, Size = 16, Color = Colors.White }, ""Zoom in"", viewer.ZoomIn);
+ var close = Control(new Icon { Kind = IconName.X, Size = 16, Color = Colors.White }, ""Close"", () => viewer.SetOpen(false));
+
+ var controls = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ Spacing = 8,
+ Margin = new Thickness(16),
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Children = { _zoomOut, reset, _zoomIn, close }
+ };
+
+ Child = new Panel { Children = { backdrop, Image, controls } };
+ ClipToBounds = true;
+ // max-h-[90vh] max-w-[90vw]
+ SizeChanged += (_, e) =>
+ {
+ Image.MaxWidth = e.NewSize.Width * 0.9;
+ Image.MaxHeight = e.NewSize.Height * 0.9;
+ };
+ ShowZoom(1);
+ }
+
+ public void ShowZoom(double zoom)
+ {
+ _percent.Text = $""{zoom * 100:0}%"";
+ _zoomOut.Opacity = zoom <= ImageViewer.MinZoom ? 0.5 : 1;
+ _zoomIn.Opacity = zoom >= ImageViewer.MaxZoom ? 0.5 : 1;
+ Image.RenderTransform = Avalonia.Media.Transformation.TransformOperations.Parse(
+ FormattableString.Invariant($""scale({zoom})""));
+ }
+
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ switch (e.Key)
+ {
+ case Key.OemPlus or Key.Add:
+ _viewer.ZoomIn();
+ break;
+ case Key.OemMinus or Key.Subtract:
+ _viewer.ZoomOut();
+ break;
+ case Key.D0 or Key.NumPad0:
+ _viewer.ResetZoom();
+ break;
+ default:
+ return;
+ }
+ e.Handled = true;
+ }
+
+ protected override void OnPointerWheelChanged(PointerWheelEventArgs e)
+ {
+ base.OnPointerWheelChanged(e);
+ if (e.Delta.Y > 0) _viewer.ZoomIn();
+ else if (e.Delta.Y < 0) _viewer.ZoomOut();
+ e.Handled = true;
+ }
+
+ // h-9 w-9 rounded-md bg-white/10 text-white hover:bg-white/20; a tab stop (Enter / Space).
+ private static Border Control(Control content, string name, Action action)
+ {
+ content.HorizontalAlignment = HorizontalAlignment.Center;
+ content.VerticalAlignment = VerticalAlignment.Center;
+ var normal = new SolidColorBrush(Colors.White, 0.1);
+ var hover = new SolidColorBrush(Colors.White, 0.2);
+ var control = new Border
+ {
+ Child = content,
+ Width = 36,
+ Height = 36,
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ Background = normal,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ AutomationProperties.SetName(control, name);
+ ShellFocus.Ring(control, control);
+ control.PointerEntered += (_, _) => control.Background = hover;
+ control.PointerExited += (_, _) => control.Background = normal;
+ control.PointerPressed += (_, e) => e.Handled = true;
+ control.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left) return;
+ action();
+ e.Handled = true;
+ };
+ control.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space)) return;
+ action();
+ e.Handled = true;
+ };
+ return control;
+ }
+
+ public async Task AnimateAsync(bool open)
+ {
+ if (open)
+ {
+ ShellMotion.Set(this, 0, ""scale(1)"");
+ await ShellMotion.To(this, 1, ""scale(1)"", 150, new CubicEaseOut());
+ }
+ else
+ {
+ await ShellMotion.To(this, 0, ""scale(1)"", 120, new CubicEaseIn());
+ }
+ }
+}
+"
+ };
+}
diff --git a/src/ShellUI.Native.Templates/Templates/PrevNextNavTemplate.cs b/src/ShellUI.Native.Templates/Templates/PrevNextNavTemplate.cs
new file mode 100644
index 0000000..717a3ae
--- /dev/null
+++ b/src/ShellUI.Native.Templates/Templates/PrevNextNavTemplate.cs
@@ -0,0 +1,279 @@
+using ShellUI.Native.Core.Models;
+
+namespace ShellUI.Native.Templates.Templates;
+
+public static class PrevNextNavTemplate
+{
+ public static ComponentMetadata Metadata => new()
+ {
+ Name = "prev-next-nav",
+ DisplayName = "Prev Next Nav",
+ Description = "Previous and next page links for the foot of a page",
+ Category = ComponentCategory.Navigation,
+ FilePath = "PrevNextNav.cs",
+ Dependencies = new List { "shell" },
+ Tags = new List { "previous", "next", "navigation", "pager", "docs" }
+ };
+
+ public static IReadOnlyDictionary Contents { get; } = new Dictionary
+ {
+ [NativePlatform.MAUI] = @"using Microsoft.Maui.Controls.Shapes;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Previous / next links for the foot of a page — two rounded-md bordered cards side by side,
+// each a muted ""Previous"" or ""Next"" over the page's name. A side with no label is left empty.
+//
+public partial class PrevNextNav : ContentView
+{
+ public static readonly BindableProperty PreviousLabelProperty =
+ BindableProperty.Create(nameof(PreviousLabel), typeof(string), typeof(PrevNextNav), string.Empty,
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ public static readonly BindableProperty NextLabelProperty =
+ BindableProperty.Create(nameof(NextLabel), typeof(string), typeof(PrevNextNav), string.Empty,
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ // The small text over each label.
+ public static readonly BindableProperty PreviousTextProperty =
+ BindableProperty.Create(nameof(PreviousText), typeof(string), typeof(PrevNextNav), ""Previous"",
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ public static readonly BindableProperty NextTextProperty =
+ BindableProperty.Create(nameof(NextText), typeof(string), typeof(PrevNextNav), ""Next"",
+ propertyChanged: (b, o, n) => ((PrevNextNav)b).Update());
+
+ public string PreviousLabel
+ {
+ get => (string)GetValue(PreviousLabelProperty);
+ set => SetValue(PreviousLabelProperty, value);
+ }
+
+ public string NextLabel
+ {
+ get => (string)GetValue(NextLabelProperty);
+ set => SetValue(NextLabelProperty, value);
+ }
+
+ public string PreviousText
+ {
+ get => (string)GetValue(PreviousTextProperty);
+ set => SetValue(PreviousTextProperty, value);
+ }
+
+ public string NextText
+ {
+ get => (string)GetValue(NextTextProperty);
+ set => SetValue(NextTextProperty, value);
+ }
+
+ public event EventHandler? PreviousClicked;
+ public event EventHandler? NextClicked;
+
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly Label _previousText;
+ private readonly Label _previousLabel;
+ private readonly Label _nextText;
+ private readonly Label _nextLabel;
+
+ public PrevNextNav()
+ {
+ (_previous, _previousText, _previousLabel) = Card(TextAlignment.Start, () => PreviousClicked?.Invoke(this, EventArgs.Empty));
+ (_next, _nextText, _nextLabel) = Card(TextAlignment.End, () => NextClicked?.Invoke(this, EventArgs.Empty));
+
+ var grid = new Grid
+ {
+ ColumnSpacing = 16,
+ ColumnDefinitions = { new ColumnDefinition(GridLength.Star), new ColumnDefinition(GridLength.Star) }
+ };
+ grid.Add(_previous, 0, 0);
+ grid.Add(_next, 1, 0);
+ Content = grid;
+ Update();
+ }
+
+ // rounded-md border p-4, hover:bg-accent: text-sm muted over a font-medium label.
+ private (Border Card, Label Text, Label Label) Card(TextAlignment alignment, Action clicked)
+ {
+ var text = new Label { FontSize = 14, HorizontalTextAlignment = alignment };
+ text.Token(Label.TextColorProperty, ShellToken.MutedForeground);
+ var label = new Label { FontSize = 16, FontAttributes = FontAttributes.Bold, HorizontalTextAlignment = alignment, LineBreakMode = LineBreakMode.TailTruncation };
+ label.Token(Label.TextColorProperty, ShellToken.Foreground);
+ var card = new Border
+ {
+ Content = new VerticalStackLayout { Spacing = 2, Children = { text, label } },
+ Padding = new Thickness(16),
+ StrokeThickness = 1,
+ StrokeShape = new RoundRectangle { CornerRadius = ShellTheme.RadiusMd },
+ BackgroundColor = Colors.Transparent
+ };
+ card.Token(Border.StrokeProperty, ShellToken.Border);
+ var pointer = new PointerGestureRecognizer();
+ pointer.PointerEntered += (_, _) => { if (IsEnabled) card.Token(VisualElement.BackgroundColorProperty, ShellToken.Accent); };
+ pointer.PointerExited += (_, _) => { card.ClearValue(VisualElement.BackgroundColorProperty); card.BackgroundColor = Colors.Transparent; };
+ card.GestureRecognizers.Add(pointer);
+ var tap = new TapGestureRecognizer();
+ tap.Tapped += (_, _) => { if (IsEnabled) { ShellFocus.FocusPressed(card); clicked(); } };
+ card.GestureRecognizers.Add(tap);
+ ShellFocus.MakeFocusable(card, () => { if (IsEnabled) clicked(); });
+ return (card, text, label);
+ }
+
+ protected override void OnPropertyChanged(string? propertyName = null)
+ {
+ base.OnPropertyChanged(propertyName);
+ if (propertyName == IsEnabledProperty.PropertyName)
+ Opacity = IsEnabled ? 1.0 : 0.5;
+ }
+
+ private void Update()
+ {
+ _previousText.Text = PreviousText ?? string.Empty;
+ _previousLabel.Text = PreviousLabel ?? string.Empty;
+ _previous.IsVisible = !string.IsNullOrEmpty(PreviousLabel);
+ SemanticProperties.SetDescription(_previous, $""{PreviousText}: {PreviousLabel}"");
+ _nextText.Text = NextText ?? string.Empty;
+ _nextLabel.Text = NextLabel ?? string.Empty;
+ _next.IsVisible = !string.IsNullOrEmpty(NextLabel);
+ SemanticProperties.SetDescription(_next, $""{NextText}: {NextLabel}"");
+ }
+}
+",
+ [NativePlatform.Avalonia] = @"using System;
+using Avalonia;
+using Avalonia.Automation;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Media;
+
+namespace YourProjectNamespace.Components.UI;
+
+// Previous / next links for the foot of a page — two rounded-md bordered cards side by side,
+// each a muted ""Previous"" or ""Next"" over the page's name. A side with no label is left empty.
+// The cards are tab stops (Enter / Space).
+//
+public class PrevNextNav : Border
+{
+ public static readonly StyledProperty PreviousLabelProperty =
+ AvaloniaProperty.Register(nameof(PreviousLabel));
+
+ public static readonly StyledProperty NextLabelProperty =
+ AvaloniaProperty.Register(nameof(NextLabel));
+
+ // The small text over each label.
+ public static readonly StyledProperty PreviousTextProperty =
+ AvaloniaProperty.Register(nameof(PreviousText), ""Previous"");
+
+ public static readonly StyledProperty NextTextProperty =
+ AvaloniaProperty.Register(nameof(NextText), ""Next"");
+
+ private readonly Border _previous;
+ private readonly Border _next;
+ private readonly TextBlock _previousText;
+ private readonly TextBlock _previousLabel;
+ private readonly TextBlock _nextText;
+ private readonly TextBlock _nextLabel;
+
+ static PrevNextNav()
+ {
+ PreviousLabelProperty.Changed.AddClassHandler((n, _) => n.Update());
+ NextLabelProperty.Changed.AddClassHandler((n, _) => n.Update());
+ PreviousTextProperty.Changed.AddClassHandler((n, _) => n.Update());
+ NextTextProperty.Changed.AddClassHandler((n, _) => n.Update());
+ IsEnabledProperty.Changed.AddClassHandler((n, _) => n.Opacity = n.IsEnabled ? 1.0 : 0.5);
+ }
+
+ public string? PreviousLabel
+ {
+ get => GetValue(PreviousLabelProperty);
+ set => SetValue(PreviousLabelProperty, value);
+ }
+
+ public string? NextLabel
+ {
+ get => GetValue(NextLabelProperty);
+ set => SetValue(NextLabelProperty, value);
+ }
+
+ public string? PreviousText
+ {
+ get => GetValue(PreviousTextProperty);
+ set => SetValue(PreviousTextProperty, value);
+ }
+
+ public string? NextText
+ {
+ get => GetValue(NextTextProperty);
+ set => SetValue(NextTextProperty, value);
+ }
+
+ public event EventHandler? PreviousClicked;
+ public event EventHandler? NextClicked;
+
+ public PrevNextNav()
+ {
+ (_previous, _previousText, _previousLabel) = Card(TextAlignment.Left, () => PreviousClicked?.Invoke(this, EventArgs.Empty));
+ (_next, _nextText, _nextLabel) = Card(TextAlignment.Right, () => NextClicked?.Invoke(this, EventArgs.Empty));
+ Grid.SetColumn(_next, 1);
+ Child = new Grid { ColumnDefinitions = new ColumnDefinitions(""*,*""), ColumnSpacing = 16, Children = { _previous, _next } };
+ Update();
+ }
+
+ // rounded-md border p-4, hover:bg-accent: text-sm muted over a font-medium label.
+ private (Border Card, TextBlock Text, TextBlock Label) Card(TextAlignment alignment, Action clicked)
+ {
+ var text = new TextBlock { FontSize = 14, TextAlignment = alignment };
+ text.Token(TextBlock.ForegroundProperty, ShellToken.MutedForeground);
+ var label = new TextBlock { FontSize = 16, FontWeight = FontWeight.Medium, TextAlignment = alignment, TextTrimming = TextTrimming.CharacterEllipsis };
+ label.Token(TextBlock.ForegroundProperty, ShellToken.Foreground);
+ var card = new Border
+ {
+ Child = new StackPanel { Spacing = 2, Children = { text, label } },
+ Padding = new Thickness(16),
+ BorderThickness = new Thickness(1),
+ CornerRadius = new CornerRadius(ShellTheme.RadiusMd),
+ // Transparent, not null, so the whole card takes the pointer.
+ Background = Brushes.Transparent,
+ Cursor = new Cursor(StandardCursorType.Hand)
+ };
+ card.Token(BorderBrushProperty, ShellToken.Border);
+ ShellFocus.Ring(card, card);
+ card.PointerEntered += (_, _) => { if (IsEffectivelyEnabled) card.Token(BackgroundProperty, ShellToken.Accent); };
+ card.PointerExited += (_, _) =>
+ {
+ card.ClearToken(BackgroundProperty);
+ card.Background = Brushes.Transparent;
+ };
+ card.PointerReleased += (_, e) =>
+ {
+ if (e.InitialPressMouseButton != MouseButton.Left || !IsEffectivelyEnabled) return;
+ clicked();
+ e.Handled = true;
+ };
+ card.KeyDown += (_, e) =>
+ {
+ if (e.Key is not (Key.Enter or Key.Space) || !IsEffectivelyEnabled) return;
+ clicked();
+ e.Handled = true;
+ };
+ return (card, text, label);
+ }
+
+ private void Update()
+ {
+ _previousText.Text = PreviousText ?? string.Empty;
+ _previousLabel.Text = PreviousLabel ?? string.Empty;
+ _previous.IsVisible = !string.IsNullOrEmpty(PreviousLabel);
+ AutomationProperties.SetName(_previous, $""{PreviousText}: {PreviousLabel}"");
+ _nextText.Text = NextText ?? string.Empty;
+ _nextLabel.Text = NextLabel ?? string.Empty;
+ _next.IsVisible = !string.IsNullOrEmpty(NextLabel);
+ AutomationProperties.SetName(_next, $""{NextText}: {NextLabel}"");
+ }
+}
+"
+ };
+}
diff --git a/tests/ShellUI.Native.Tests/TemplateContentTests.cs b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
index a2efc2a..5a99bbf 100644
--- a/tests/ShellUI.Native.Tests/TemplateContentTests.cs
+++ b/tests/ShellUI.Native.Tests/TemplateContentTests.cs
@@ -127,6 +127,8 @@ public static IEnumerable